Class: Rubycc::Rmake::Expander

Inherits:
Object
  • Object
show all
Defined in:
lib/rubycc/rmake/expander.rb

Overview

Turns raw make text with $(...) / ${...} / $x references into its expanded string. It implements exactly the reference forms the mkmf corpus uses (test/fixtures/mkmf): plain variable references, nested references, the $(var:from=to) substitution reference, and the automatic variables ($@, $<, $^, $*, and their D/F directory/file variants). Undefined variables expand to the empty string, matching make.

Recursive (=) variables are stored unexpanded and expanded on every reference; simple (:=) variables are stored already-expanded. That split lives in the Variable value object, so the expander only has to re-expand whatever text a recursive variable holds — which is where an A=$(B) / B=$(A) cycle would otherwise recurse forever, so every variable dereference spends one unit of the depth budget below.

Constant Summary collapse

MAX_EXPANSION_DEPTH =

DoS fail-safe: the maximum number of nested variable dereferences during a single expansion. A reference cycle between recursive variables would recurse without bound; this cap converts that into an ExpansionError long before the Ruby stack is exhausted. Legitimate mkmf variable chains (e.g. rubyarchhdrdir -> rubyhdrdir -> includedir -> prefix) are well under a dozen deep, so the limit is generous.

200
MAX_EXPANSION_REFERENCES =

DoS fail-safe, part two: the depth cap above only counts how deep the call stack goes, so it says nothing about fan-out. A chain like A1 = $(A0)$(A0) / A2 = $(A1)$(A1) / ... stays only n levels deep while doing 2^n work, so a 30-line Makefile can wedge the parser (:= assignments expand at parse time, before any target is built). The two budgets below bound the total work of one top-level expansion, in the same spirit as the preprocessor's EXPANSION_TOKEN_LIMIT.

Both are needed, because each misses the explosion the other catches:

  • MAX_EXPANSION_REFERENCES counts variable dereferences. It is the only one that sees a fan-out whose leaves expand to nothing (A0 = empty still costs 2^n dereferences while producing zero characters), and it is also what catches a cycle that grows the call count rather than the text.
  • MAX_EXPANSION_OUTPUT counts characters produced. It is the only one that sees a blow-up driven by text size rather than reference count (BIG = <megabytes> referenced a handful of times per level exhausts memory long before the reference count is reached).

Both limits are orders of magnitude above what the mkmf corpus needs: measured over the rmake test suite (test/fixtures/mkmf included), the heaviest single expansion resolves 32 references and yields ~4 KB.

The reference cap is deliberately kept small rather than merely "safe": a budget is spent per top-level call, so the time it takes to reach the cap is itself the attacker's resource — a Makefile with many := assignments multiplies it by the number of assignments. 100_000 still leaves ~3000x headroom over the measured 32 while tripping in well under a second.

100_000
MAX_EXPANSION_OUTPUT =

4 MiB of characters

4_194_304
AUTOMATIC_CHARS =

Base characters of the automatic variables rmake recognises. Each may be written bare ($@) or parenthesised ($(@)), and the parenthesised form may carry a D/F modifier ($(@D) = directory part, $(@F) = file part).

%w[@ < ^ ? * + %].freeze

Instance Method Summary collapse

Constructor Details

#initialize(variables) ⇒ Expander

variables

Hash=> Variable



68
69
70
71
72
# File 'lib/rubycc/rmake/expander.rb', line 68

def initialize(variables)
  @variables = variables
  @references = 0
  @output_chars = 0
end

Instance Method Details

#expand(text, autos = nil, depth = 0) ⇒ Object

Expand text. autos is a Hash=> String keyed by automatic variable base character (e.g. => "foo.o", "<" => "foo.c"); pass nil outside a recipe, where automatic variables expand to "".



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/rubycc/rmake/expander.rb', line 77

def expand(text, autos = nil, depth = 0)
  # depth 0 is the entry point from outside, so one external call gets one
  # budget; the recursive calls below all pass depth + 1 and therefore
  # share it.
  if depth.zero?
    @references = 0
    @output_chars = 0
  end
  guard_depth(depth)
  out = +""
  i = 0
  n = text.length
  while i < n
    c = text[i]
    if c == "$"
      consumed, value = expand_reference(text, i, autos, depth)
      @output_chars += value.length
      guard_output
      out << value
      i += consumed
    else
      @output_chars += 1
      guard_output
      out << c
      i += 1
    end
  end
  out
end