furnace-avm2 1.0.1 → 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +1 -1
- data/bin/furnace-avm2 +15 -4
- data/furnace-avm2.gemspec +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_load_float32.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_load_float64.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_load_int16.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_load_int32.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_load_int8.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_store_float32.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_store_float64.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_store_int16.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_store_int32.rb +1 -1
- data/lib/furnace-avm2/abc/opcodes/alchemy/alchemy_store_int8.rb +1 -1
- data/lib/furnace-avm2/source/declaration_tokens/token_with_traits.rb +10 -2
- data/lib/furnace-avm2/source/decompiler.rb +41 -9
- data/lib/furnace-avm2/source/implementation_tokens/with_token.rb +9 -0
- data/lib/furnace-avm2/transform/cfg_reduce.rb +38 -3
- data/lib/furnace-avm2/transform/nf_normalize.rb +111 -0
- data/lib/furnace-avm2/version.rb +1 -1
- metadata +6 -6
data/README.md
CHANGED
@@ -81,7 +81,7 @@ Contrary to that, `furnace-avm2-decompiler` works on a class level. You can incl
|
|
81
81
|
|
82
82
|
The `-D funids` option adds a comment with method body index for each decompiled method. It can be used for debugging decompiler failures.
|
83
83
|
|
84
|
-
You'll notice that some methods probably will not get decompiled. (The file I used in this example is quite complex.) Not every possible bytecode sequence can be directly represented in ActionScript 3, and there are some corner cases yet to be described in the decompiler. For "partially decompiled" (i.e. where there were no control flow uncertainites, but some expressions were impossible to transform to ActionScript) the relevant NF-AST code is automatically emitted. You can look at it manually with `furnace-avm2 -n`. For "failed" methods there is no generated code, but you might try to look at control flow graph (`furnace-avm2 -C`, look for emitted `method-*.dot` file) in [Graphviz](http://en.wikipedia.org/wiki/Graphviz) format to understand the logic.
|
84
|
+
You'll notice that some methods probably will not get decompiled. (The file I used in this example is quite complex.) Not every possible bytecode sequence can be directly represented in ActionScript 3, and there are some corner cases yet to be described in the decompiler. For "partially decompiled" (i.e. where there were no control flow uncertainites, but some expressions were impossible to transform to ActionScript) the relevant NF-AST code is automatically emitted. You can look at it manually with `furnace-avm2 -n`. For "failed" methods there is no generated code, but you might try to look at control flow graph (`furnace-avm2 -C graphviz`, look for emitted `method-*.dot` file) in [Graphviz](http://en.wikipedia.org/wiki/Graphviz) format to understand the logic.
|
85
85
|
|
86
86
|
Programming interface
|
87
87
|
---------------------
|
data/bin/furnace-avm2
CHANGED
@@ -15,11 +15,15 @@ require "benchmark"
|
|
15
15
|
|
16
16
|
include Furnace
|
17
17
|
|
18
|
+
GRAPH_FORMATS = %w(none graphviz)
|
19
|
+
|
18
20
|
opts = Trollop::options do
|
19
21
|
version "furnace-avm2 #{AVM2::VERSION}"
|
20
22
|
banner <<-EOS
|
21
23
|
furnace-avm2 is a processing tool which operates on ActionScript3 bytecode.
|
22
24
|
|
25
|
+
Supported graphing formats: #{GRAPH_FORMATS.join(", ")}.
|
26
|
+
|
23
27
|
Usage: #{__FILE__} [options]
|
24
28
|
EOS
|
25
29
|
|
@@ -31,14 +35,14 @@ EOS
|
|
31
35
|
|
32
36
|
opt :only, "Only operate on methods <i+>", :type => :ints, :short => '-O'
|
33
37
|
opt :except, "Operate on all methods except <i+>", :type => :ints, :short => '-E'
|
34
|
-
opt :grep, "Search <s> in method names", :type => :string, :short => '-
|
38
|
+
opt :grep, "Search <s> in method names", :type => :string, :short => '-g'
|
35
39
|
|
36
40
|
opt :collect, "Collect failed methods instead of exiting", :default => false
|
37
41
|
opt :sort_by_size, "Sort methods by body size", :default => false
|
38
42
|
|
39
43
|
opt :disasm_before, "Disassemble methods before transforming", :default => false, :short => '-B'
|
40
44
|
opt :disasm_after, "Disassemble methods after transforming", :default => false, :short => '-A'
|
41
|
-
opt :cfg, "Emit CFG in
|
45
|
+
opt :cfg, "Emit CFG in specified format for methods", :type => :string, :short => '-G'
|
42
46
|
|
43
47
|
opt :dce, "Eliminate dead code", :default => false
|
44
48
|
opt :fix_names, "Remove invalid characters from names", :default => true, :short => '-q'
|
@@ -49,6 +53,10 @@ end
|
|
49
53
|
|
50
54
|
Trollop::die "Stray arguments: #{ARGV}" unless ARGV.empty?
|
51
55
|
|
56
|
+
if opts[:cfg] && !GRAPH_FORMATS.include?(opts[:cfg])
|
57
|
+
Trollop::die "Unsupported graphing format."
|
58
|
+
end
|
59
|
+
|
52
60
|
abc = nil
|
53
61
|
File.open(opts[:input]) do |file|
|
54
62
|
abc = AVM2::ABC::File.new
|
@@ -150,8 +158,11 @@ opts[:threads].times do
|
|
150
158
|
|
151
159
|
if opts[:cfg]
|
152
160
|
cfg, = body.code_to_cfg
|
153
|
-
|
154
|
-
|
161
|
+
|
162
|
+
if opts[:cfg] == 'graphviz'
|
163
|
+
File.open("method-#{body.method_idx}.dot", "w") do |dot|
|
164
|
+
dot.write cfg.to_graphviz
|
165
|
+
end
|
155
166
|
end
|
156
167
|
|
157
168
|
puts "Method #{body.method_idx}; dominators"
|
data/furnace-avm2.gemspec
CHANGED
@@ -17,6 +17,6 @@ Gem::Specification.new do |s|
|
|
17
17
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
18
|
s.require_paths = ["lib"]
|
19
19
|
|
20
|
-
s.add_runtime_dependency "furnace", '= 0.2.
|
20
|
+
s.add_runtime_dependency "furnace", '= 0.2.5'
|
21
21
|
s.add_runtime_dependency "trollop"
|
22
22
|
end
|
@@ -9,8 +9,11 @@ module Furnace::AVM2::Tokens
|
|
9
9
|
|
10
10
|
if options[:environment] == :class && options[:static]
|
11
11
|
if origin.initializer_body
|
12
|
-
|
13
|
-
origin.initializer_body, options)
|
12
|
+
initializer_decompiler = Furnace::AVM2::Decompiler.new(
|
13
|
+
origin.initializer_body, options.merge(global_code: true))
|
14
|
+
properties = initializer_decompiler.decompose_static_initializer
|
15
|
+
static_initialization = initializer_decompiler.decompile
|
16
|
+
|
14
17
|
options = options.merge(property_values: properties)
|
15
18
|
end
|
16
19
|
end
|
@@ -21,6 +24,11 @@ module Furnace::AVM2::Tokens
|
|
21
24
|
tokens << Furnace::Code::NewlineToken.new(origin, options)
|
22
25
|
end
|
23
26
|
|
27
|
+
if static_initialization && static_initialization.children.any?
|
28
|
+
tokens << static_initialization
|
29
|
+
tokens << Furnace::Code::NewlineToken.new(origin, options)
|
30
|
+
end
|
31
|
+
|
24
32
|
if options[:environment] == :class && !options[:static]
|
25
33
|
tokens << ConstructorToken.new(origin, options)
|
26
34
|
end
|
@@ -15,15 +15,15 @@ module Furnace::AVM2
|
|
15
15
|
def initialize(body, options)
|
16
16
|
@body, @method, @options = body, body.method, options.dup
|
17
17
|
@closure = @options.delete(:closure)
|
18
|
+
|
19
|
+
@scopes = []
|
20
|
+
@metascopes = []
|
21
|
+
@catch_scopes = {}
|
18
22
|
end
|
19
23
|
|
20
24
|
def decompile
|
21
25
|
begin
|
22
26
|
@locals = Set.new([0]) + (1..@method.param_count).to_a
|
23
|
-
@scopes = []
|
24
|
-
@metascopes = []
|
25
|
-
|
26
|
-
@catch_scopes = {}
|
27
27
|
|
28
28
|
@closure_locals = Set.new
|
29
29
|
|
@@ -34,7 +34,9 @@ module Furnace::AVM2
|
|
34
34
|
|
35
35
|
@global_slots = @options[:global_slots] || {}
|
36
36
|
|
37
|
-
|
37
|
+
@scopes << :this if @options[:global_code]
|
38
|
+
|
39
|
+
stmt_block (@nf || @body.code_to_nf),
|
38
40
|
function: !@options[:global_code],
|
39
41
|
closure: @closure
|
40
42
|
|
@@ -76,8 +78,13 @@ module Furnace::AVM2
|
|
76
78
|
|
77
79
|
def decompose_static_initializer
|
78
80
|
properties = {}
|
81
|
+
matches = []
|
82
|
+
|
83
|
+
@nf = @body.code_to_nf
|
84
|
+
|
85
|
+
StaticProperty.find_all(@nf.children) do |match, captures|
|
86
|
+
matches.push match
|
79
87
|
|
80
|
-
StaticProperty.find_all(@body.code_to_nf.children) do |match, captures|
|
81
88
|
begin
|
82
89
|
token = handle_expression(captures[:value])
|
83
90
|
rescue ExpressionNotRecognized => e
|
@@ -87,6 +94,8 @@ module Furnace::AVM2
|
|
87
94
|
properties[captures[:property]] = token
|
88
95
|
end
|
89
96
|
|
97
|
+
@nf.children -= matches
|
98
|
+
|
90
99
|
properties
|
91
100
|
end
|
92
101
|
|
@@ -339,6 +348,17 @@ module Furnace::AVM2
|
|
339
348
|
end
|
340
349
|
end
|
341
350
|
|
351
|
+
def stmt_with(opcode, nodes)
|
352
|
+
object, scope = opcode.children
|
353
|
+
|
354
|
+
@scopes << :with
|
355
|
+
nodes << token(WithToken,
|
356
|
+
expr(object),
|
357
|
+
stmt_block(scope))
|
358
|
+
ensure
|
359
|
+
@scopes.pop
|
360
|
+
end
|
361
|
+
|
342
362
|
# Expressions
|
343
363
|
|
344
364
|
def handle_expression(opcode)
|
@@ -821,7 +841,7 @@ module Furnace::AVM2
|
|
821
841
|
],
|
822
842
|
[
|
823
843
|
either[
|
824
|
-
[:get_scope_object,
|
844
|
+
[:get_scope_object, capture(:scope)],
|
825
845
|
[:get_global_scope]
|
826
846
|
],
|
827
847
|
capture(:multiname)
|
@@ -830,6 +850,10 @@ module Furnace::AVM2
|
|
830
850
|
capture_rest(:arguments)]
|
831
851
|
end
|
832
852
|
|
853
|
+
def pseudo_global_scope?(scope)
|
854
|
+
[:this, :with].include?(@scopes[scope || 0])
|
855
|
+
end
|
856
|
+
|
833
857
|
def expr_get_lex(opcode)
|
834
858
|
multiname, = opcode.children
|
835
859
|
get_name(nil, multiname)
|
@@ -837,6 +861,7 @@ module Furnace::AVM2
|
|
837
861
|
|
838
862
|
def expr_get_property(opcode)
|
839
863
|
if captures = PropertyGlobal.match(opcode)
|
864
|
+
return if !captures[:multiname] && !pseudo_global_scope?(captures[:scope])
|
840
865
|
get_name(nil, captures[:multiname])
|
841
866
|
else
|
842
867
|
subject, multiname, = opcode.children
|
@@ -860,6 +885,7 @@ module Furnace::AVM2
|
|
860
885
|
|
861
886
|
def expr_set_property(opcode)
|
862
887
|
if captures = PropertyGlobal.match(opcode)
|
888
|
+
return if !captures[:multiname] && !pseudo_global_scope?(captures[:scope])
|
863
889
|
token(AssignmentToken, [
|
864
890
|
get_name(nil, captures[:multiname]),
|
865
891
|
parenthesize(expr(*captures[:arguments]))
|
@@ -893,6 +919,7 @@ module Furnace::AVM2
|
|
893
919
|
|
894
920
|
def expr_do_property(opcode, klass, has_args)
|
895
921
|
if captures = PropertyGlobal.match(opcode)
|
922
|
+
return if !captures[:multiname] && !pseudo_global_scope?(captures[:scope])
|
896
923
|
token(klass, [
|
897
924
|
get_name(nil, captures[:multiname]),
|
898
925
|
(token(ArgumentsToken, exprs(captures[:arguments])) if has_args)
|
@@ -1118,13 +1145,18 @@ module Furnace::AVM2
|
|
1118
1145
|
origin = multiname.metadata[:origin]
|
1119
1146
|
case origin.kind
|
1120
1147
|
when :QName, :QNameA, :Multiname, :MultinameA
|
1148
|
+
prefix = nil
|
1149
|
+
if [:QNameA, :MultinameA].include? origin.kind
|
1150
|
+
prefix = '@'
|
1151
|
+
end
|
1152
|
+
|
1121
1153
|
if subject
|
1122
1154
|
token(AccessToken, [
|
1123
1155
|
parenthesize(subject),
|
1124
|
-
token(PropertyNameToken, origin.name)
|
1156
|
+
token(PropertyNameToken, "#{prefix}#{origin.name}")
|
1125
1157
|
])
|
1126
1158
|
else
|
1127
|
-
token(PropertyNameToken, origin.name)
|
1159
|
+
token(PropertyNameToken, "#{prefix}#{origin.name}")
|
1128
1160
|
end
|
1129
1161
|
when :MultinameL, :MultinameLA
|
1130
1162
|
if subject
|
@@ -235,9 +235,9 @@ module Furnace::AVM2
|
|
235
235
|
log nesting, "exit point (second guess): #{exit_point.inspect}"
|
236
236
|
end
|
237
237
|
|
238
|
-
if exit_point.nil?
|
238
|
+
if exit_point.nil? || @dom[exit_point].include?(stopgap)
|
239
239
|
exit_point = stopgap
|
240
|
-
log nesting, "exit point (
|
240
|
+
log nesting, "exit point (third guess): stopgap #{stopgap.inspect}"
|
241
241
|
end
|
242
242
|
|
243
243
|
# Flatten the one-element sets.
|
@@ -305,7 +305,11 @@ module Furnace::AVM2
|
|
305
305
|
node
|
306
306
|
])
|
307
307
|
|
308
|
-
block
|
308
|
+
if reachable?(exit_point, [ block ])
|
309
|
+
block = exit_point
|
310
|
+
else
|
311
|
+
block = nil
|
312
|
+
end
|
309
313
|
elsif @loops.include?(block) && !@postcond_heads.include?(block)
|
310
314
|
# we're trapped in a strange loop
|
311
315
|
if block.insns.first == block.cti &&
|
@@ -548,6 +552,37 @@ module Furnace::AVM2
|
|
548
552
|
end
|
549
553
|
end
|
550
554
|
|
555
|
+
# Check if a block is reachable from sources.
|
556
|
+
def reachable?(block, sources)
|
557
|
+
worklist = sources.to_set
|
558
|
+
visited = Set[]
|
559
|
+
|
560
|
+
while worklist.any?
|
561
|
+
node = worklist.first
|
562
|
+
worklist.delete node
|
563
|
+
|
564
|
+
return true if node == block
|
565
|
+
|
566
|
+
visited.add node
|
567
|
+
|
568
|
+
node.targets.each do |target|
|
569
|
+
# Skip visited nodes.
|
570
|
+
if visited.include?(target)
|
571
|
+
next
|
572
|
+
end
|
573
|
+
|
574
|
+
# Skip back edges.
|
575
|
+
if @dom[node].include?(target)
|
576
|
+
next
|
577
|
+
end
|
578
|
+
|
579
|
+
worklist.add target
|
580
|
+
end
|
581
|
+
end
|
582
|
+
|
583
|
+
false
|
584
|
+
end
|
585
|
+
|
551
586
|
# Find a set of merge points for a set of partially diverged
|
552
587
|
# paths beginning from `heads'.
|
553
588
|
# E.g. here:
|
@@ -178,6 +178,117 @@ module Furnace::AVM2
|
|
178
178
|
])
|
179
179
|
end
|
180
180
|
end
|
181
|
+
|
182
|
+
def on_begin(node)
|
183
|
+
# Fold (with)'s
|
184
|
+
with_begin = node.children.index do |child|
|
185
|
+
child.type == :push_with
|
186
|
+
end
|
187
|
+
with_end = nil
|
188
|
+
|
189
|
+
if with_begin
|
190
|
+
nesting = 0
|
191
|
+
node.children[with_begin..-1].each_with_index do |child, index|
|
192
|
+
if child.type == :push_with || child.type == :push_scope
|
193
|
+
nesting += 1
|
194
|
+
elsif child.type == :pop_scope
|
195
|
+
nesting -= 1
|
196
|
+
if nesting == 0
|
197
|
+
with_end = with_begin + index
|
198
|
+
break
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
if nesting == 0
|
204
|
+
with_scope, = node.children[with_begin].children
|
205
|
+
with_content = node.children.slice (with_begin + 1)..(with_end - 1)
|
206
|
+
|
207
|
+
with_node = AST::Node.new(:with, [
|
208
|
+
with_scope,
|
209
|
+
AST::Node.new(:begin,
|
210
|
+
with_content
|
211
|
+
)
|
212
|
+
])
|
213
|
+
|
214
|
+
node.children.slice! with_begin..with_end
|
215
|
+
node.children.insert with_begin, with_node
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Remove obviously dead code
|
220
|
+
first_ctn = node.children.index do |child|
|
221
|
+
[:return_void, :return_value, :break, :continue, :throw].include? child.type
|
222
|
+
end
|
223
|
+
return unless first_ctn
|
224
|
+
|
225
|
+
node.children.slice! (first_ctn + 1)..-1
|
226
|
+
end
|
227
|
+
|
228
|
+
OptimizedSwitchSeed = AST::Matcher.new do
|
229
|
+
[:ternary,
|
230
|
+
[:===, capture(:case_value),
|
231
|
+
[:get_local, capture(:local_index)]],
|
232
|
+
[:integer, capture(:case_index)],
|
233
|
+
capture(:nested)]
|
234
|
+
end
|
235
|
+
|
236
|
+
OptimizedSwitchNested = AST::Matcher.new do
|
237
|
+
either[
|
238
|
+
[:ternary,
|
239
|
+
[:===, capture(:case_value),
|
240
|
+
[:get_local, backref(:local_index)]],
|
241
|
+
[:integer, capture(:case_index)],
|
242
|
+
capture(:nested)],
|
243
|
+
[:integer, capture(:default_index)]
|
244
|
+
]
|
245
|
+
end
|
246
|
+
|
247
|
+
NumericCase = AST::Matcher.new do
|
248
|
+
[:case, [:integer, capture(:index)]]
|
249
|
+
end
|
250
|
+
|
251
|
+
def on_switch(node)
|
252
|
+
condition, body = node.children
|
253
|
+
|
254
|
+
if captures = OptimizedSwitchSeed.match(condition)
|
255
|
+
mapping = { captures[:case_index] => captures[:case_value] }
|
256
|
+
while captures = OptimizedSwitchNested.match(captures[:nested], captures)
|
257
|
+
break if captures[:default_index]
|
258
|
+
mapping[captures[:case_index]] = captures[:case_value]
|
259
|
+
end
|
260
|
+
|
261
|
+
return if captures.nil?
|
262
|
+
|
263
|
+
case_mapping = {}
|
264
|
+
|
265
|
+
body.children.each do |child|
|
266
|
+
if case_captures = NumericCase.match(child)
|
267
|
+
case_index = case_captures[:index]
|
268
|
+
if captures[:default_index] == case_index
|
269
|
+
case_mapping[child] = nil
|
270
|
+
elsif mapping.has_key?(case_index)
|
271
|
+
case_mapping[child] = mapping[case_index]
|
272
|
+
else
|
273
|
+
# fallback
|
274
|
+
return
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
# At this point, we are sure that this switch can be transformed.
|
280
|
+
|
281
|
+
node.children[0] = AST::Node.new(:get_local, [ captures[:local_index] ])
|
282
|
+
|
283
|
+
case_mapping.each do |child, value|
|
284
|
+
if value.nil?
|
285
|
+
body.children.delete child
|
286
|
+
else
|
287
|
+
child.children[0] = value
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
181
292
|
end
|
182
293
|
end
|
183
294
|
end
|
data/lib/furnace-avm2/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: furnace-avm2
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.0.
|
4
|
+
version: 1.0.2
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-07-07 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: furnace
|
@@ -18,7 +18,7 @@ dependencies:
|
|
18
18
|
requirements:
|
19
19
|
- - '='
|
20
20
|
- !ruby/object:Gem::Version
|
21
|
-
version: 0.2.
|
21
|
+
version: 0.2.5
|
22
22
|
type: :runtime
|
23
23
|
prerelease: false
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -26,7 +26,7 @@ dependencies:
|
|
26
26
|
requirements:
|
27
27
|
- - '='
|
28
28
|
- !ruby/object:Gem::Version
|
29
|
-
version: 0.2.
|
29
|
+
version: 0.2.5
|
30
30
|
- !ruby/object:Gem::Dependency
|
31
31
|
name: trollop
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
@@ -344,6 +344,7 @@ files:
|
|
344
344
|
- lib/furnace-avm2/source/implementation_tokens/unary_post_operator_token.rb
|
345
345
|
- lib/furnace-avm2/source/implementation_tokens/variable_name_token.rb
|
346
346
|
- lib/furnace-avm2/source/implementation_tokens/while_token.rb
|
347
|
+
- lib/furnace-avm2/source/implementation_tokens/with_token.rb
|
347
348
|
- lib/furnace-avm2/source/implementation_tokens/xml_literal_token.rb
|
348
349
|
- lib/furnace-avm2/transform.rb
|
349
350
|
- lib/furnace-avm2/transform/ast_build.rb
|
@@ -377,9 +378,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
377
378
|
version: '0'
|
378
379
|
requirements: []
|
379
380
|
rubyforge_project:
|
380
|
-
rubygems_version: 1.8.
|
381
|
+
rubygems_version: 1.8.24
|
381
382
|
signing_key:
|
382
383
|
specification_version: 3
|
383
384
|
summary: AVM2 analysis framework based on Furnace
|
384
385
|
test_files: []
|
385
|
-
has_rdoc:
|