lint_fu 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/MIT-LICENSE +20 -0
- data/README.rdoc +20 -0
- data/bin/lint_fu +92 -0
- data/lib/lint_fu/action_pack/model_controller.rb +7 -0
- data/lib/lint_fu/action_pack/model_controller_builder.rb +26 -0
- data/lib/lint_fu/action_pack.rb +2 -0
- data/lib/lint_fu/active_record/model_model.rb +21 -0
- data/lib/lint_fu/active_record/model_model_builder.rb +72 -0
- data/lib/lint_fu/active_record.rb +2 -0
- data/lib/lint_fu/checker.rb +11 -0
- data/lib/lint_fu/issue.rb +45 -0
- data/lib/lint_fu/mixins/file_class_methods.rb +16 -0
- data/lib/lint_fu/mixins/sexp_instance_methods.rb +111 -0
- data/lib/lint_fu/mixins/symbol_instance_methods.rb +13 -0
- data/lib/lint_fu/mixins.rb +3 -0
- data/lib/lint_fu/model_element.rb +48 -0
- data/lib/lint_fu/model_element_builder.rb +29 -0
- data/lib/lint_fu/parser.rb +14 -0
- data/lib/lint_fu/rails/buggy_eager_load_checker.rb +110 -0
- data/lib/lint_fu/rails/model_application.rb +16 -0
- data/lib/lint_fu/rails/model_application_builder.rb +32 -0
- data/lib/lint_fu/rails/scan_builder.rb +43 -0
- data/lib/lint_fu/rails/sql_injection_checker.rb +133 -0
- data/lib/lint_fu/rails/unsafe_find_checker.rb +122 -0
- data/lib/lint_fu/rails.rb +6 -0
- data/lib/lint_fu/report.rb +239 -0
- data/lib/lint_fu/scan.rb +64 -0
- data/lib/lint_fu/source_control/git.rb +53 -0
- data/lib/lint_fu/source_control_provider.rb +58 -0
- data/lib/lint_fu/visitor.rb +36 -0
- data/lib/lint_fu.rb +34 -0
- data/lint_fu.gemspec +35 -0
- metadata +144 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Tony Spataro
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
= LintFu
|
2
|
+
|
3
|
+
Lint-Fu uses some very basic static analysis tricks to find bugs in your Rails
|
4
|
+
app. It produces a report (HTML format by default) explaining what it found.
|
5
|
+
|
6
|
+
Although Lint-Fu was built for Rails applications, it is modular and extensible
|
7
|
+
and rules can be developed for any framework or toolkit, or even for Ruby code
|
8
|
+
in general.
|
9
|
+
|
10
|
+
Lint-Fu's capabilities are limited to semantic and structural analysis of the
|
11
|
+
code; it does model data or control flow. This means it spots a *very small*
|
12
|
+
subset of all potential bugs that it knows how to look for. Furthermore, it
|
13
|
+
will sometimes report false positives.
|
14
|
+
|
15
|
+
= Example
|
16
|
+
|
17
|
+
$ rake lint
|
18
|
+
|
19
|
+
|
20
|
+
Copyright (c) 2009 Tony Spataro, released under the MIT license
|
data/bin/lint_fu
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
basedir = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
|
4
|
+
$LOAD_PATH.unshift(basedir) unless $LOAD_PATH.include?(basedir)
|
5
|
+
|
6
|
+
require 'lint_fu'
|
7
|
+
|
8
|
+
def timed(activity)
|
9
|
+
print activity, '...'
|
10
|
+
STDOUT.flush
|
11
|
+
t0 = Time.now.to_i
|
12
|
+
yield
|
13
|
+
t1 = Time.now.to_i
|
14
|
+
dt = t1-t0
|
15
|
+
if dt > 0
|
16
|
+
puts "done (#{t1-t0} sec)"
|
17
|
+
STDOUT.flush
|
18
|
+
else
|
19
|
+
puts "done"
|
20
|
+
STDOUT.flush
|
21
|
+
end
|
22
|
+
rescue Exception => e
|
23
|
+
puts 'error!' #ensure we print a newline
|
24
|
+
raise e
|
25
|
+
end
|
26
|
+
|
27
|
+
app_root = File.expand_path('.')
|
28
|
+
|
29
|
+
#Only define the Rake tasks if the plugin loaded successfully
|
30
|
+
@scm = LintFu::SourceControlProvider.for_directory(app_root)
|
31
|
+
|
32
|
+
#Build a model of the application we are scanning.
|
33
|
+
timed("Build a model of the application") do
|
34
|
+
builder = LintFu::Rails::ModelApplicationBuilder.new(app_root)
|
35
|
+
@application = builder.model_elements[0]
|
36
|
+
raise LintFu::ProviderError.new("Unable to identify the source control provider for #{app_root}") unless @scm
|
37
|
+
end
|
38
|
+
|
39
|
+
#Using the model we built, scan the controllers for security bugs.
|
40
|
+
timed("Scan the application") do
|
41
|
+
builder = LintFu::Rails::ScanBuilder.new(app_root)
|
42
|
+
@scan = builder.scan(@application)
|
43
|
+
end
|
44
|
+
|
45
|
+
@genuine_issues = @scan.issues.select { |i| !@scan.blessed?(i) }
|
46
|
+
if @genuine_issues.empty?
|
47
|
+
puts "Clean scan: no issues found. Skipping report."
|
48
|
+
exit(0)
|
49
|
+
end
|
50
|
+
|
51
|
+
#CruiseControl.rb integration: write our report to the CC build artifacts folder
|
52
|
+
output_dir = ENV['CC_BUILD_ARTIFACTS'] || app_root
|
53
|
+
mkdir_p output_dir unless File.directory?(output_dir)
|
54
|
+
|
55
|
+
flavor = ENV['FORMAT'] || 'html'
|
56
|
+
typename = "#{flavor}_report".camelize
|
57
|
+
|
58
|
+
#Use a filename (or STDOUT) for our report that corresponds to its format
|
59
|
+
case flavor
|
60
|
+
when 'html'
|
61
|
+
output_name = File.join(output_dir, 'lint.html')
|
62
|
+
output = File.open(output_name, 'w')
|
63
|
+
when 'text'
|
64
|
+
output = STDOUT
|
65
|
+
else
|
66
|
+
puts "Unrecognized output format #{flavor} (undefined type #{typename})"
|
67
|
+
exit -1
|
68
|
+
end
|
69
|
+
|
70
|
+
klass = LintFu.const_get(typename.to_sym)
|
71
|
+
|
72
|
+
timed("Generate report") do
|
73
|
+
klass.new(@scan, @scm, @genuine_issues).generate(output)
|
74
|
+
output.close
|
75
|
+
end
|
76
|
+
|
77
|
+
#Support automation jobs that need to distinguish between failure due to
|
78
|
+
#broken environment and failure to due issues that were genuinely found by
|
79
|
+
#the lint task.
|
80
|
+
if ENV['STATUS_IF_ISSUES']
|
81
|
+
if(@genuine_issues.size > 0)
|
82
|
+
retval = ENV['STATUS_IF_ISSUES'].to_i
|
83
|
+
else
|
84
|
+
retval = 0
|
85
|
+
end
|
86
|
+
else
|
87
|
+
retval = [@genuine_issues.size, 255].min
|
88
|
+
end
|
89
|
+
|
90
|
+
system("open #{output_name}") if (output != STDOUT && STDOUT.tty?)
|
91
|
+
|
92
|
+
exit( retval )
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module LintFu
|
2
|
+
module ActionPack
|
3
|
+
class ModelControllerBuilder < ModelElementBuilder
|
4
|
+
SIGNATURE_SEXP = s(:colon2, s(:const, :ActionController), :Base)
|
5
|
+
|
6
|
+
#sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
|
7
|
+
def process_class(sexp)
|
8
|
+
return super(sexp) unless sexp[2] && sexp[2] == SIGNATURE_SEXP
|
9
|
+
|
10
|
+
unless @current_model_element
|
11
|
+
@current_model_element = ModelController.new(sexp, self.namespace)
|
12
|
+
did_element = true
|
13
|
+
end
|
14
|
+
|
15
|
+
ret = super(sexp)
|
16
|
+
|
17
|
+
if did_element
|
18
|
+
self.model_elements.push @current_model_element
|
19
|
+
@current_model_element = nil
|
20
|
+
end
|
21
|
+
|
22
|
+
return ret
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module LintFu
|
2
|
+
module ActiveRecord
|
3
|
+
class ModelModel
|
4
|
+
include LintFu::ModelElement
|
5
|
+
|
6
|
+
attr_reader :associations
|
7
|
+
attr_reader :named_scopes
|
8
|
+
attr_writer :paranoid
|
9
|
+
|
10
|
+
def initialize(sexp, namespace=nil)
|
11
|
+
super(sexp, namespace)
|
12
|
+
@associations = {}
|
13
|
+
@named_scopes = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def paranoid?
|
17
|
+
!!@paranoid
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module LintFu
|
2
|
+
module ActiveRecord
|
3
|
+
class ModelModelBuilder < ModelElementBuilder
|
4
|
+
SIGNATURE_SEXP = s(:colon2, s(:const, :ActiveRecord), :Base)
|
5
|
+
|
6
|
+
SINGULAR_ASSOCS = Set.new([:belongs_to, :has_one])
|
7
|
+
PLURAL_ASSOCS = Set.new([:has_many, :has_and_belongs_to_many])
|
8
|
+
ASSOCS = SINGULAR_ASSOCS + PLURAL_ASSOCS
|
9
|
+
|
10
|
+
#sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
|
11
|
+
def process_class(sexp)
|
12
|
+
return super(sexp) unless sexp[2] && sexp[2] == SIGNATURE_SEXP
|
13
|
+
|
14
|
+
unless @current_model_element
|
15
|
+
@current_model_element = ModelModel.new(sexp, self.namespace)
|
16
|
+
did_element = true
|
17
|
+
end
|
18
|
+
|
19
|
+
ret = super(sexp)
|
20
|
+
|
21
|
+
if did_element
|
22
|
+
self.model_elements.push @current_model_element
|
23
|
+
@current_model_element = nil
|
24
|
+
end
|
25
|
+
|
26
|
+
return ret
|
27
|
+
end
|
28
|
+
|
29
|
+
#s(:call, nil, :belongs_to, s(:arglist, s(:lit, :relation_name)))
|
30
|
+
def process_call(sexp)
|
31
|
+
return sexp unless @current_model_element
|
32
|
+
|
33
|
+
callee, method, arglist = sexp[1], sexp[2], sexp[3]
|
34
|
+
arglist = nil unless arglist[0] == :arglist
|
35
|
+
discover_associations(callee, method, arglist)
|
36
|
+
discover_named_scopes(callee, method, arglist)
|
37
|
+
discover_paranoia(callee, method, arglist)
|
38
|
+
return sexp
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def discover_associations(callee, method, arglist)
|
43
|
+
#Is the call declaring an ActiveRecord association?
|
44
|
+
if (callee == nil) && ASSOCS.include?(method) && arglist
|
45
|
+
assoc_name = arglist[1].to_ruby
|
46
|
+
|
47
|
+
# TODO grok :class_name option
|
48
|
+
if SINGULAR_ASSOCS.include?(method)
|
49
|
+
assoc_class_name = assoc_name.to_s.camelize
|
50
|
+
else
|
51
|
+
assoc_class_name = assoc_name.to_s.singularize.camelize
|
52
|
+
end
|
53
|
+
|
54
|
+
@current_model_element.associations[assoc_name] = assoc_class_name
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def discover_named_scopes(callee, method, arglist)
|
59
|
+
#Is the call declaring a named scope?
|
60
|
+
if (callee == nil && method == :named_scope) && arglist
|
61
|
+
scope_name = arglist[1].to_ruby
|
62
|
+
scope_args = arglist[2..-1].to_ruby(:partial=>nil)
|
63
|
+
@current_model_element.named_scopes[scope_name] = scope_args
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def discover_paranoia(callee, method, arglist)
|
68
|
+
@current_model_element.paranoid = true if (method == :acts_as_paranoid)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module LintFu
|
2
|
+
class Issue
|
3
|
+
attr_reader :file, :sexp, :confidence
|
4
|
+
|
5
|
+
def initialize(scan, file, sexp, confidence=1.0)
|
6
|
+
@scan = scan
|
7
|
+
@file = file
|
8
|
+
@sexp = sexp.deep_clone
|
9
|
+
self.confidence = confidence
|
10
|
+
end
|
11
|
+
|
12
|
+
def line
|
13
|
+
@sexp.line
|
14
|
+
end
|
15
|
+
|
16
|
+
def relative_file
|
17
|
+
File.relative_path(@scan.fs_root, file)
|
18
|
+
end
|
19
|
+
|
20
|
+
def brief
|
21
|
+
self.class.name.split('::')[-1].underscore.gsub('_', ' ').titleize
|
22
|
+
end
|
23
|
+
|
24
|
+
def detail
|
25
|
+
"There is an issue at #{file_basename}:#{line}."
|
26
|
+
end
|
27
|
+
|
28
|
+
def reference_info
|
29
|
+
"No reference information is available for #{brief}."
|
30
|
+
end
|
31
|
+
|
32
|
+
def file_basename
|
33
|
+
File.basename(file)
|
34
|
+
end
|
35
|
+
|
36
|
+
def issue_hash()
|
37
|
+
Digest::SHA1.hexdigest("#{self.class.name} - #{self.relative_file} - #{sexp.fingerprint}")
|
38
|
+
end
|
39
|
+
|
40
|
+
def confidence=(confidence)
|
41
|
+
raise ArgumentError, "Confidence must be a real number in the range (0..1)" unless (0..1).include?(confidence)
|
42
|
+
@confidence = confidence
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module LintFu
|
2
|
+
module Mixins
|
3
|
+
module FileClassMethods
|
4
|
+
def relative_path(base, path)
|
5
|
+
base = File.expand_path(base)
|
6
|
+
path = File.expand_path(path)
|
7
|
+
raise Errno::ENOENT unless path.index(base) == 0
|
8
|
+
return path[base.length+1..-1]
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class <<File
|
15
|
+
include LintFu::Mixins::FileClassMethods
|
16
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module LintFu
|
2
|
+
module Mixins
|
3
|
+
module SexpInstanceMethods
|
4
|
+
def deep_clone
|
5
|
+
Marshal.load(Marshal.dump(self))
|
6
|
+
end
|
7
|
+
|
8
|
+
#Return a version of this Sexp that preserves the structure of the original, but with
|
9
|
+
#any specific names, quantities or other values replaced with nil. The fingerprint of
|
10
|
+
#a given chunk of code will tend to remain the same over time, even if variable names
|
11
|
+
#or other inconsequential details are changed.
|
12
|
+
#TODO actually implement this method
|
13
|
+
def fingerprint
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
#Generate a human-readable description for this sexp that is similar to source code.
|
18
|
+
def to_ruby_string
|
19
|
+
Ruby2Ruby.new.process(self.deep_clone)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Translate a sexp containing a Ruby data literal (string, int, array, hash, etc) into the equivalent Ruby object.
|
23
|
+
def to_ruby(options={})
|
24
|
+
typ = self[0]
|
25
|
+
|
26
|
+
case typ
|
27
|
+
when :true
|
28
|
+
return true
|
29
|
+
when :false
|
30
|
+
return false
|
31
|
+
when :lit, :str
|
32
|
+
return self[1]
|
33
|
+
when :array, :arglist
|
34
|
+
return self[1..-1].collect { |x| x.to_ruby(options) }
|
35
|
+
when :hash
|
36
|
+
result = {}
|
37
|
+
key, value = nil, nil
|
38
|
+
flipflop = false
|
39
|
+
self[1..-1].each do |token|
|
40
|
+
if flipflop
|
41
|
+
value = token
|
42
|
+
result[key.to_ruby(options)] = value.to_ruby(options)
|
43
|
+
else
|
44
|
+
key = token
|
45
|
+
end
|
46
|
+
flipflop = !flipflop
|
47
|
+
end
|
48
|
+
return result
|
49
|
+
else
|
50
|
+
return options[:partial] if options.has_key?(:partial)
|
51
|
+
raise StandardError.new("Cannot convert Sexp to Ruby object: " + self.to_s)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def constant?
|
56
|
+
typ = self[0]
|
57
|
+
|
58
|
+
case typ
|
59
|
+
when :true, :false, :lit, :str
|
60
|
+
return true
|
61
|
+
when :array, :arglist
|
62
|
+
self[1..-1].each { |sexp| return false unless sexp.constant? }
|
63
|
+
return true
|
64
|
+
when :hash
|
65
|
+
result = {}
|
66
|
+
key, value = nil, nil
|
67
|
+
flipflop = false
|
68
|
+
self[1..-1].each do |token|
|
69
|
+
if flipflop
|
70
|
+
value = token
|
71
|
+
return false unless key.constant? && value.constant?
|
72
|
+
else
|
73
|
+
key = token
|
74
|
+
end
|
75
|
+
flipflop = !flipflop
|
76
|
+
end
|
77
|
+
return true
|
78
|
+
else
|
79
|
+
return false
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def find_recursively(&test)
|
84
|
+
return self if test.call(self)
|
85
|
+
|
86
|
+
self.each do |child|
|
87
|
+
found = child.find_recursively(&test) if (Sexp === child)
|
88
|
+
return found if found
|
89
|
+
end
|
90
|
+
|
91
|
+
return nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def find_all_recursively(results=nil, &test)
|
95
|
+
results ||= []
|
96
|
+
results << self if test.call(self)
|
97
|
+
|
98
|
+
self.each do |child|
|
99
|
+
child.find_all_recursively(results, &test) if (Sexp === child)
|
100
|
+
end
|
101
|
+
|
102
|
+
return nil if results.empty?
|
103
|
+
return results
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
Sexp.instance_eval do
|
110
|
+
include LintFu::Mixins::SexpInstanceMethods
|
111
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module LintFu
|
2
|
+
# An element of a static analysis model that contains, or consists of, submodels. For instance,
|
3
|
+
# an Application might consists of Models, Controllers and Views.
|
4
|
+
module SuperModel
|
5
|
+
def submodels
|
6
|
+
return [].freeze unless @submodels
|
7
|
+
@submodels.dup.freeze
|
8
|
+
end
|
9
|
+
|
10
|
+
def each_submodel(&block)
|
11
|
+
@submodels ||= Set.new()
|
12
|
+
@submodels.each(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
def add_submodel(sub)
|
16
|
+
@submodels ||= Set.new()
|
17
|
+
@submodels << sub
|
18
|
+
end
|
19
|
+
|
20
|
+
def remove_submodel(sub)
|
21
|
+
@submodels ||= Set.new()
|
22
|
+
@submodels.delete sub
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# An element of the static analysis model being created; generally corresponds to a
|
27
|
+
# class (e.g. model, controller or view) within the application being scanned.
|
28
|
+
module ModelElement
|
29
|
+
attr_accessor :supermodel
|
30
|
+
attr_reader :modeled_class_name, :modeled_class_superclass_name, :parse_tree
|
31
|
+
|
32
|
+
#sexp:: [:class, <classname>, <superclass|nil>, <CLASS DEFS>]
|
33
|
+
#namespace:: Array of enclosing module names for this class
|
34
|
+
def initialize(sexp, namespace=nil)
|
35
|
+
@parse_tree = sexp
|
36
|
+
if namespace
|
37
|
+
@modeled_class_name = namespace.join('::') + (namespace.empty? ? '' : '::') + sexp[1].to_s
|
38
|
+
else
|
39
|
+
@modeled_class_name = sexp[1]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
#Have a pretty string representation
|
44
|
+
def to_s
|
45
|
+
"<<model of #{modeled_class_name}>>"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module LintFu
|
2
|
+
class ModelElementBuilder < SexpProcessor
|
3
|
+
attr_reader :model_elements, :namespace
|
4
|
+
|
5
|
+
def initialize(namespace=nil)
|
6
|
+
super()
|
7
|
+
@model_elements = []
|
8
|
+
@namespace = namespace || []
|
9
|
+
self.require_empty = false
|
10
|
+
self.auto_shift_type = false
|
11
|
+
end
|
12
|
+
|
13
|
+
#sexp:: [:module, <modulename>, <:scope - MODULE DEFS>]
|
14
|
+
def process_module(sexp)
|
15
|
+
@namespace.push sexp[1]
|
16
|
+
process(sexp[2])
|
17
|
+
@namespace.pop
|
18
|
+
return sexp
|
19
|
+
end
|
20
|
+
|
21
|
+
#sexp:: [:class, <classname>, <superclass|nil>, <:scope - CLASS DEFS>]
|
22
|
+
def process_class(sexp)
|
23
|
+
@namespace.push sexp[1]
|
24
|
+
process(sexp[3])
|
25
|
+
@namespace.pop
|
26
|
+
return sexp
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module LintFu
|
2
|
+
module Parser
|
3
|
+
def self.parse_ruby(filename)
|
4
|
+
contents = File.read(filename)
|
5
|
+
sexp = RubyParser.new.parse(contents)
|
6
|
+
sexp.file = filename
|
7
|
+
return sexp
|
8
|
+
rescue SyntaxError => e
|
9
|
+
e2 = SyntaxError.new "In #{filename}: #{e.message}"
|
10
|
+
e2.set_backtrace(e.backtrace)
|
11
|
+
raise e2
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module LintFu
|
2
|
+
module Rails
|
3
|
+
class BuggyEagerLoad < Issue
|
4
|
+
def initialize(scan, file, sexp, subject)
|
5
|
+
super(scan, file, sexp)
|
6
|
+
@subject = subject
|
7
|
+
end
|
8
|
+
|
9
|
+
def detail
|
10
|
+
"Instances of the paranoid model <code>#{@subject}</code> are being eager-loaded. This may cause unexpected results."
|
11
|
+
end
|
12
|
+
|
13
|
+
def reference_info
|
14
|
+
return <<EOF
|
15
|
+
h4. What is it?
|
16
|
+
|
17
|
+
A buggy eager load happens when an ActiveRecord finder performs eager loading of a @:has_many@ association and the "target" of the association acts as paranoid.
|
18
|
+
|
19
|
+
The acts_as_paranoid plugin does not correctly eager loads that use a @JOIN@ strategy. If a paranoid model is eager loaded in this way, _all_ models -- even deleted ones -- will be loaded.
|
20
|
+
|
21
|
+
h4. When does it happen?
|
22
|
+
|
23
|
+
A finder with any of the following properties will cause Rails to eager-load using @JOIN@
|
24
|
+
|
25
|
+
* complex @:conditions@ option (containing SQL fragments, or referring to tables using strings)
|
26
|
+
* complex @:order@
|
27
|
+
* complex @:join@
|
28
|
+
* complex @:include@
|
29
|
+
* use of named scopes (which almost always add complex options to the query)
|
30
|
+
|
31
|
+
If your find exhibits any of these properties and it @:include@s a paranoid model, then you have a problem.
|
32
|
+
|
33
|
+
h4. How do I fix it?
|
34
|
+
|
35
|
+
Avoid doing complex finds at the same time you @:include@ a paranoid model.
|
36
|
+
|
37
|
+
EOF
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Visit a Rails controller looking for troublesome stuff
|
42
|
+
class BuggyEagerLoadChecker < Checker
|
43
|
+
FINDER_REGEXP = /^(find|first|all)(_or_initialize)?(_by_.*)?/
|
44
|
+
|
45
|
+
#sexp:: s(:call, <target>, <method_name>, s(:arglist))
|
46
|
+
def observe_call(sexp)
|
47
|
+
return unless finder?(sexp)
|
48
|
+
if finder?(sexp) && (si = spotty_includes(sexp[3]))
|
49
|
+
scan.issues << BuggyEagerLoad.new(scan, self.file, sexp, si.modeled_class_name)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
protected
|
54
|
+
|
55
|
+
def finder?(sexp)
|
56
|
+
!!(sexp[2].to_s =~ FINDER_REGEXP)
|
57
|
+
end
|
58
|
+
|
59
|
+
def spotty_includes(sexp)
|
60
|
+
#transform the sexp (if it's an arglist) into a hash for easier scrutiny
|
61
|
+
arglist = ( sexp && (sexp[0] == :arglist) && sexp.to_ruby(:partial=>nil) )
|
62
|
+
#no dice unless we're looking at an arglist
|
63
|
+
return nil unless arglist
|
64
|
+
|
65
|
+
#no options hash in arglist? no problem!
|
66
|
+
return nil unless (options = arglist.last).is_a?(Hash)
|
67
|
+
|
68
|
+
does_eager_loading = options.has_key?(:include)
|
69
|
+
has_complexity_prone_params =
|
70
|
+
options.has_key?(:conditions) ||
|
71
|
+
options.has_key?(:order) ||
|
72
|
+
options.has_key?(:joins) ||
|
73
|
+
options.has_key?(:group)
|
74
|
+
|
75
|
+
#no eager loading, or no complexity-prone params? no problem!
|
76
|
+
return nil unless does_eager_loading && has_complexity_prone_params
|
77
|
+
|
78
|
+
gather_includes(arglist.last[:include]).each do |inc|
|
79
|
+
plural = inc.to_s
|
80
|
+
singular = plural.singularize
|
81
|
+
class_name = singular.camelize
|
82
|
+
type = self.context.models.detect { |m| m.modeled_class_name == class_name }
|
83
|
+
# If we're eager loading a 1:1 association, don't bother to scream; it's likely
|
84
|
+
# that use the user would want to load the deleted thing anyway.
|
85
|
+
# TODO replace this clever hack, which infers a :has_many association using plurality
|
86
|
+
if !type || ( type.paranoid? && (plural != singular) )
|
87
|
+
return type
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
return nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def gather_includes(data)
|
95
|
+
out = []
|
96
|
+
case data
|
97
|
+
when Array
|
98
|
+
data.each { |elem| out += gather_includes(elem) }
|
99
|
+
when Hash
|
100
|
+
data.keys.each { |elem| out += gather_includes(elem) }
|
101
|
+
data.values.each { |elem| out += gather_includes(elem) }
|
102
|
+
when Symbol
|
103
|
+
out << data
|
104
|
+
end
|
105
|
+
|
106
|
+
return out
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|