headdesk 0.1.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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'headdesk/apk/field'
4
+ require 'headdesk/apk/method'
5
+
6
+ module Headdesk
7
+ class Apk
8
+ #
9
+ # A Smali bytecode class
10
+ #
11
+ class Class
12
+ # Formats:
13
+ # android/content/Context
14
+ # android.content.Context
15
+ def self.path_for(decl)
16
+ File.join(*decl.split(%r{[\/,\.]}))
17
+ end
18
+
19
+ def initialize(smali_file)
20
+ @smali = File.read(smali_file)
21
+ end
22
+
23
+ def method?(name)
24
+ method(name) != false
25
+ end
26
+
27
+ def method(name)
28
+ matchdata = /(^\.method .* #{name}.*$[\s\S]*?\.end method)/.match(@smali)
29
+ return nil unless matchdata
30
+
31
+ Method.new(matchdata)
32
+ end
33
+
34
+ def field?(name)
35
+ field(name) != false
36
+ end
37
+
38
+ def field(name)
39
+ matchdata = /^\.field .* #{name}.* = "(.*)"$/.match(@smali)
40
+ return nil unless matchdata
41
+
42
+ Field.new(matchdata)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Headdesk
4
+ class Apk
5
+ #
6
+ # A Smali bytecode field
7
+ #
8
+ class Field
9
+ attr_reader :code, :value
10
+
11
+ def initialize(matchdata)
12
+ @code = matchdata[0]
13
+ @value = matchdata[1]
14
+
15
+ @value.extend(ExtraMethods)
16
+ end
17
+ end
18
+
19
+ #
20
+ # Extra methods for 'value'
21
+ #
22
+ module ExtraMethods
23
+ def to_version
24
+ /^(\d+\.)?(\d+\.)?(\*|\d+)$/.match(self).captures.map(&:to_i)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Headdesk
4
+ class Apk
5
+ #
6
+ # A Smali bytecode method
7
+ #
8
+ class Method
9
+ attr_reader :code
10
+
11
+ def initialize(matchdata)
12
+ @code = matchdata[0]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+
5
+ module Headdesk
6
+ class Apk
7
+ #
8
+ # Android Resources searching
9
+ #
10
+ class Resources
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ def values(modifiers = {})
16
+ XmlCollection.new(@path, 'values', modifiers)
17
+ end
18
+
19
+ #
20
+ # Collection of XML values for specific locale/api/etc
21
+ #
22
+ class XmlCollection
23
+ # :reek:NestedIterators and :reek:TooManyStatements
24
+ def initialize(path, type, modifiers = {})
25
+ @resources = {}
26
+ globspec = File.join(path, 'res', "#{type}{#{XmlCollection.api_versions(modifiers).join(',')}}", '*.xml')
27
+ Dir.glob(globspec).each do |file_name|
28
+ xml = File.open(file_name) { |file| Nokogiri::XML(file) }
29
+
30
+ @resources.merge! XmlCollection.named_elements(xml)
31
+ @resources.merge! XmlCollection.item_elements(xml)
32
+ end
33
+ end
34
+
35
+ def respond_to_missing?(method_name, include_all)
36
+ @resources.include?(method_name.to_s) || super
37
+ end
38
+
39
+ def method_missing(method_name, *arguments, &block)
40
+ super unless @resources.include?(method_name.to_s)
41
+
42
+ OpenStruct.new(@resources[method_name.to_s])
43
+ end
44
+
45
+ private
46
+
47
+ def self.api_versions(modifiers)
48
+ mods = [nil]
49
+ if modifiers.key?(:v)
50
+ (1..modifiers[:v].to_i).each do |api_version|
51
+ mods << "-v#{api_version}"
52
+ end
53
+ end
54
+ mods
55
+ end
56
+
57
+ # :reek:TooManyStatements
58
+ def self.named_elements(xml)
59
+ named_elements = %i[string integer color bool]
60
+
61
+ resources = {}
62
+ xml.xpath("//#{named_elements.join('|//')}").each do |elem|
63
+ type = elem.name.to_s
64
+ name = elem.attributes['name'].to_s
65
+
66
+ resources[type] ||= {}
67
+ resources[type][name] = case type
68
+ when 'bool'
69
+ elem.text == true.to_s
70
+ when 'integer'
71
+ elem.text.to_i
72
+ else
73
+ elem.text
74
+ end
75
+ end
76
+ resources
77
+ end
78
+
79
+ # :reek:TooManyStatements
80
+ def self.item_elements(xml)
81
+ item_elements = %i[drawable]
82
+ resources = {}
83
+ xml.xpath("//item[#{item_elements.map { |elem| "contains(@type, '#{elem}')" }.join('or')}]").each do |elem|
84
+ type = elem.attributes['type'].to_s
85
+ name = elem.attributes['name'].to_s
86
+
87
+ resources[type] ||= {}
88
+ resources[type][name] = elem.text
89
+ end
90
+ resources
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Headdesk
6
+ #
7
+ # Wrapper around using https://ibotpeaches.github.io/Apktool/
8
+ #
9
+ class ApkTool
10
+ def self.apktool_jar
11
+ "ext/apktool_#{APKTOOL_VERSION}.jar"
12
+ end
13
+
14
+ #
15
+ # Run apktool command
16
+ #
17
+ # :reek:TooManyStatements
18
+ def self.cmd(*args)
19
+ _stdin, stdout, stderr, wait_thr = Open3.popen3('java', '-jar', apktool_jar, *args)
20
+ r_stdout = stdout.gets(nil)
21
+ stdout.close
22
+ r_stderr = stderr.gets(nil)
23
+ stderr.close
24
+ r_exit_code = wait_thr.value
25
+
26
+ [r_stdout, r_stderr, r_exit_code]
27
+ end
28
+
29
+ #
30
+ # Unpacks an APK to the specified path
31
+ #
32
+ # :reek:TooManyStatements
33
+ def self.unpack_to(path, destination)
34
+ throw CliError.new("File not found: #{path}") unless File.exist?(path)
35
+ throw CliError.new("Path not found: #{destination}") unless Dir.exist?(destination)
36
+
37
+ args = ['d', '--force', '--output', destination]
38
+
39
+ stdout, stderr, exit_code = Headdesk::ApkTool.cmd(*args, path)
40
+ raise stderr unless exit_code.to_i.zero?
41
+
42
+ stdout
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'headdesk/descriptionator'
4
+
5
+ module Headdesk
6
+ #
7
+ # Check for a potential issue in an apk or ipa
8
+ #
9
+ # :reek:ModuleInitialize
10
+ module Check
11
+ attr_reader :report, :status, :apk, :ipa
12
+
13
+ def self.for_apk
14
+ APK.all
15
+ end
16
+
17
+ def self.for_ipa
18
+ IPA.all
19
+ end
20
+
21
+ def self.included(klass)
22
+ klass.extend(ClassMethods)
23
+ end
24
+
25
+ #
26
+ # Class methods for Check
27
+ #
28
+ module ClassMethods
29
+ def describe(desc = nil)
30
+ @last_desc = desc if desc
31
+ @last_desc
32
+ end
33
+ end
34
+
35
+ def initialize(bundle)
36
+ @apk = bundle
37
+ @ipa = bundle
38
+ @report = {
39
+ description: self.class.describe,
40
+ steps: [],
41
+ export: {}
42
+ }
43
+ @status = :skip
44
+ end
45
+
46
+ def describe(desc = nil)
47
+ @last_desc = desc if desc
48
+ @last_desc
49
+ end
50
+
51
+ # :reek:ManualDispatch and :reek:TooManyStatements and :reek:FeatureEnvy
52
+ def check_control_flow(status_to_assign, conditions = nil)
53
+ pass = !conditions || conditions.empty?
54
+ raise ArgumentError, 'Do not specify both if: and unless:' if
55
+ conditions.key?(:if) && conditions.key?(:unless)
56
+
57
+ pass = Check.condition?(conditions, :if) if conditions.key? :if
58
+ pass = !Check.condition?(conditions, :unless) if conditions.key? :unless
59
+
60
+ skip = false
61
+ raise ArgumentError, 'Do not specify both skip_if: and skip_unless:' if
62
+ conditions.key?(:skip_if) && conditions.key?(:skip_unless)
63
+
64
+ skip = Check.condition?(conditions, :skip_if) if conditions.key? :skip_if
65
+ skip = !Check.condition?(conditions, :skip_unless) if conditions.key? :skip_unless
66
+
67
+ # TODO: greater_than, less_than, equals
68
+
69
+ # rubocop:disable RescueStandardError
70
+ # Try and get an auto-description
71
+ default_description = describe.to_s
72
+ description = begin
73
+ if conditions[:unless].respond_to?(:call)
74
+ descriptionator = Headdesk::Descriptionator.new(:unless)
75
+ desc = descriptionator.instance_exec(&conditions[:unless])
76
+ desc.is_a?(String) ? desc : default_description
77
+ elsif conditions[:if].respond_to?(:call)
78
+ descriptionator = Headdesk::Descriptionator.new(:if)
79
+ desc = descriptionator.instance_exec(&conditions[:if])
80
+ desc.is_a?(String) ? desc : default_description
81
+ else
82
+ default_description
83
+ end
84
+ rescue
85
+ default_description
86
+ end
87
+ # rubocop:enable RescueStandardError
88
+
89
+ @status = status_to_assign if pass && !skip
90
+ @report[:steps] << {
91
+ description: description,
92
+ status: skip ? :skip : @status
93
+ }
94
+ return unless pass
95
+
96
+ throw :halt_check
97
+ end
98
+
99
+ def skip_check(conditions = {})
100
+ check_control_flow(:skip, conditions)
101
+ end
102
+
103
+ def fail_check(conditions = {})
104
+ check_control_flow(:fail, conditions)
105
+ end
106
+
107
+ def export(merge = {})
108
+ @report[:export].merge! merge
109
+ end
110
+
111
+ def preconditions?
112
+ true
113
+ end
114
+
115
+ def run
116
+ before
117
+ catch(:halt_check) do
118
+ call
119
+ end
120
+ after
121
+
122
+ @status
123
+ end
124
+
125
+ # :reek:ManualDispatch
126
+ def process
127
+ return report unless respond_to?(:call) && preconditions?
128
+
129
+ @status = :success
130
+ report[:status] = run
131
+ report
132
+ end
133
+
134
+ def before; end
135
+
136
+ def after; end
137
+
138
+ #
139
+ # Check applies to APKs
140
+ #
141
+ module APK
142
+ def self.all
143
+ @all
144
+ end
145
+
146
+ def self.included(klass)
147
+ @all ||= []
148
+ @all << klass
149
+ klass.include(Check)
150
+ end
151
+ end
152
+
153
+ #
154
+ # Check applies to IPAs
155
+ #
156
+ module IPA
157
+ def self.all
158
+ @all
159
+ end
160
+
161
+ def self.included(klass)
162
+ @all ||= []
163
+ @all << klass
164
+ klass.include(Check)
165
+ end
166
+ end
167
+
168
+ # :reek:ManualDispatch
169
+ def self.condition?(conditions, key)
170
+ condition = conditions.fetch(key, nil)
171
+ if !condition
172
+ false
173
+ elsif condition.respond_to? :call
174
+ condition.call
175
+ elsif %w[true false].include?(condition.to_s)
176
+ condition.to_s == 'true'
177
+ else
178
+ raise ArgumentError, 'fail_check and skip_check only accept truthy, falsy, nil, or Proc arguments'
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ Dir[File.dirname(__FILE__) + '/checks/*.rb'].each { |file| require file }
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Headdesk
4
+ module Checks
5
+ #
6
+ # APKs should all target API 26+
7
+ #
8
+ # :reek:UncommunicativeModuleName
9
+ class Api26
10
+ include Check::APK
11
+
12
+ describe 'targetSdkVersion must be at least 26'
13
+ def call
14
+ fail_check unless: -> { apk.targets_sdk 26 }
15
+
16
+ klass_def = 'android/support/v4/app/NotificationCompat$Builder'
17
+ skip_check unless: -> { apk.class?(klass_def) }
18
+ klass = apk.find_class(klass_def)
19
+
20
+ describe 'support-v4 version is 26.1+'
21
+ fail_check unless: -> { klass.method?('setChannelId') }
22
+
23
+ # TODO: AndroidX/JetPack
24
+ end
25
+ end
26
+ end
27
+ end