rubocop-dev_doc 0.10.4 → 0.12.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +73 -0
  3. data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
  4. data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
  5. data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
  6. data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
  7. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
  8. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
  9. data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
  10. data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
  11. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +81 -58
  12. data/lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb +176 -0
  13. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
  14. data/lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb +102 -0
  15. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
  16. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
  17. data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
  18. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
  19. data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
  20. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
  21. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
  22. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
  23. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
  24. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
  25. data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
  26. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
  27. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
  28. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
  29. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
  30. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
  31. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
  32. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
  33. data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
  34. data/lib/rubocop/dev_doc/plugin.rb +2 -0
  35. data/lib/rubocop/dev_doc/version.rb +1 -1
  36. metadata +8 -2
@@ -0,0 +1,176 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # Condition analysis for CurrentUserBranching, extracted so the cop
6
+ # class stays within size limits. Everything here answers one
7
+ # question: is this AST node auth-state/authorization branching?
8
+ #
9
+ # Three mechanisms cooperate (see the cop docstring for doctrine):
10
+ # - AUTH ROOTS: bare `current_user` / `user_signed_in?` calls, PLUS
11
+ # any local/instance variable assigned from `current_user` in the
12
+ # same file (taint tracking — `@user = current_user` must not be a
13
+ # way to launder the branch). Taint is per-file: an ivar assigned in
14
+ # a controller and read in a view is NOT tracked — the companion
15
+ # role-predicate cop covers that gap receiver-agnostically.
16
+ # - ALLOWED METHODS: the first method called on an auth root is
17
+ # classified. Data reads (`errors`, project-configured readers like
18
+ # `otp_required_for_login`) pass; everything else — role predicates,
19
+ # unknown methods — flags until someone classifies it in
20
+ # `AllowedMethods` (a deliberate, reviewed decision).
21
+ # - GUARD EXEMPTION: bare-return nil guards in every spelling stay
22
+ # allowed (the correct pre-auth pattern).
23
+ module CurrentUserBranchingHelpers
24
+ # Universal non-permission data reads, allowed WITHOUT configuration.
25
+ # Lives in code (not default.yml) so a project setting its own
26
+ # AllowedMethods cannot accidentally drop the baseline — RuboCop
27
+ # REPLACES array config wholesale, it does not merge. The yml key is
28
+ # therefore purely additive.
29
+ DEFAULT_ALLOWED_METHODS = %i[errors id email persisted? new_record?].freeze
30
+
31
+ # Nil-test spellings that keep the guard-return exemption; role/data
32
+ # chains never qualify.
33
+ NIL_TEST_METHODS = %i[present? blank? nil? !].freeze
34
+
35
+ # Identity comparisons are display logic, not access decisions.
36
+ IDENTITY_COMPARISON_METHODS = %i[== !=].freeze
37
+
38
+ def on_new_investigation
39
+ super
40
+ @tainted_names = collect_tainted_names(processed_source.ast)
41
+ end
42
+
43
+ private
44
+
45
+ # Names that are PURE aliases of current_user: every assignment to
46
+ # the name, file-wide, is an unconditional `= current_user`. An
47
+ # assignment under an `if` is semantic narrowing (`@user =
48
+ # current_user` only when a token matches — the variable is
49
+ # genuinely nilable, and branching on it is resource-presence
50
+ # logic), and an assignment from any other source makes it a
51
+ # general holder; either disqualifies the name. `case` dispatch
52
+ # (glib_load_resource's per-action assignment) does NOT disqualify —
53
+ # per-action aliasing is still pure aliasing.
54
+ def collect_tainted_names(ast)
55
+ names = Set.new
56
+ disqualified = Set.new
57
+ ast&.each_node(:lvasgn, :ivasgn) do |assign|
58
+ target = pure_alias_assignment?(assign) ? names : disqualified
59
+ target << assign.children[0]
60
+ end
61
+ names - disqualified
62
+ end
63
+
64
+ def pure_alias_assignment?(assign)
65
+ value = assign.children[1]
66
+ return false unless value&.send_type? && value.method_name == :current_user && value.receiver.nil?
67
+
68
+ assign.each_ancestor(:if).none?
69
+ end
70
+
71
+ def tainted_ref?(node)
72
+ node&.type?(:lvar, :ivar) && @tainted_names.include?(node.children.first)
73
+ end
74
+
75
+ def auth_condition?(condition)
76
+ return false unless condition
77
+ return tainted_ref?(condition) if condition.type?(:lvar, :ivar)
78
+ return auth_condition?(condition.children.last) if condition.begin_type?
79
+ return condition.children.any? { |operand| auth_condition?(operand) } if condition.type?(:and, :or)
80
+
81
+ condition.type?(:send, :csend) && auth_send_condition?(condition)
82
+ end
83
+
84
+ def auth_send_condition?(node)
85
+ return true if bare_auth_call?(node)
86
+ # Identity comparisons are exempt in BOTH directions
87
+ # (`x.user == current_user` and `current_user.id == x`), so test
88
+ # before the chain-root rule.
89
+ return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
90
+ # `!current_user` / `!user_signed_in?` (or a negated chain)
91
+ return auth_send_condition?(node.receiver) if node.method_name == :! && node.receiver&.type?(:send, :csend)
92
+ return flagged_chain_off_auth_root?(node) if chain_first_hop(node)
93
+
94
+ current_user_as_access_argument?(node)
95
+ end
96
+
97
+ # The first method called on an auth root is the classified one:
98
+ # `current_user.oauth_tokens.any?` classifies `oauth_tokens` (the
99
+ # rest operates on the returned data). Allowed data reads pass;
100
+ # role predicates and unknown methods flag.
101
+ def flagged_chain_off_auth_root?(node)
102
+ !allowed_method?(chain_first_hop(node).method_name)
103
+ end
104
+
105
+ # The send whose receiver IS an auth root (bare current_user or a
106
+ # tainted alias) — nil when the chain isn't rooted at auth state.
107
+ # `user_signed_in?` accepts no chain (it returns a bool).
108
+ def chain_first_hop(node)
109
+ current = node
110
+ while current.respond_to?(:receiver) && current.receiver
111
+ return current if auth_root?(current.receiver)
112
+
113
+ current = current.receiver
114
+ end
115
+ nil
116
+ end
117
+
118
+ def auth_root?(node)
119
+ (node.send_type? && node.method_name == :current_user && node.receiver.nil?) || tainted_ref?(node)
120
+ end
121
+
122
+ def bare_auth_call?(node)
123
+ node.send_type? && self.class::AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
124
+ end
125
+
126
+ def allowed_method?(method_name)
127
+ DEFAULT_ALLOWED_METHODS.include?(method_name) ||
128
+ (cop_config['AllowedMethods'] || []).map(&:to_sym).include?(method_name)
129
+ end
130
+
131
+ # `admins.include?(current_user)` / `accessible_by?(current_user)`
132
+ # in a condition — an access decision parameterised by the current
133
+ # user (or a tainted alias); belongs in the policy.
134
+ def current_user_as_access_argument?(node)
135
+ return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
136
+
137
+ node.arguments.any? do |arg|
138
+ (arg.send_type? && arg.method_name == :current_user && arg.receiver.nil?) || tainted_ref?(arg)
139
+ end
140
+ end
141
+
142
+ # `return unless current_user` (and every nil-test SPELLING of it,
143
+ # on current_user or a tainted alias) is the correct guard pattern —
144
+ # the cop polices branching, not guard orthography. Role/data chains
145
+ # (`return unless current_user.admin?`) do NOT qualify.
146
+ def bare_return_guard?(node)
147
+ return false unless pure_auth_state_test?(node.condition)
148
+
149
+ bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
150
+ end
151
+
152
+ def pure_auth_state_test?(condition)
153
+ return false if condition.nil?
154
+ return true if tainted_ref?(condition)
155
+ return false unless condition.type?(:send, :csend)
156
+ return true if bare_current_user?(condition)
157
+
158
+ nil_test_of_auth_state?(condition)
159
+ end
160
+
161
+ def nil_test_of_auth_state?(node)
162
+ NIL_TEST_METHODS.include?(node.method_name) && node.receiver && pure_auth_state_test?(node.receiver)
163
+ end
164
+
165
+ def bare_current_user?(node)
166
+ node.method_name == :current_user && node.receiver.nil?
167
+ end
168
+
169
+ def bare_return_branch?(branch)
170
+ branch&.return_type? && branch.children.empty?
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -274,11 +274,11 @@ module RuboCop
274
274
  # `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
275
275
  # LAST statement does.
276
276
  def branch_exits?(branch)
277
+ branch = branch.children.last if branch&.begin_type?
277
278
  return false unless branch
278
279
 
279
- branch = branch.children.last if branch.begin_type?
280
- branch&.return_type? ||
281
- (branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
280
+ branch.return_type? ||
281
+ (branch.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
282
282
  end
283
283
  end
284
284
  end
@@ -0,0 +1,102 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # Role predicates must not be called outside the policy layer.
6
+ #
7
+ # ## Rationale
8
+ # A role predicate (`admin?`, `super_admin?`,
9
+ # `effective_admin_of_organization?`, ...) answers "what may this
10
+ # person do?" — an authorization question. Scattering those calls
11
+ # through controllers and views fragments the permission model: the
12
+ # Pundit policy says one thing, page code quietly decides another.
13
+ #
14
+ # This cop is the receiver-agnostic companion to
15
+ # `DevDoc/Auth/CurrentUserBranching`: that cop tracks `current_user`
16
+ # and its same-file aliases, but taint cannot follow an ivar from a
17
+ # controller into a view, and any wrapper method breaks the chain.
18
+ # Keying on the PREDICATE NAME instead means `current_user.admin?`,
19
+ # `@user.admin?`, `user.admin?`, and `viewer.admin?` are all equally
20
+ # visible — no alias or wrapper hides the question being asked.
21
+ #
22
+ # ## Remedy
23
+ # Move the decision into the Pundit policy and query it where needed:
24
+ # `policy(record).manage?` — no role predicate at the call site.
25
+ # Name the policy query after the DECISION it guards (`auto_approve?`,
26
+ # `initiation_blocked_for_admin?`), never after the role: a generic
27
+ # getter (`user_is_admin?`) re-exports the scattering through the
28
+ # policy door — every call site still maps role to behavior itself.
29
+ # Genuine display logic keyed on a role (badges, admin-only hints)
30
+ # belongs in a helper (an excluded path), where the branching is
31
+ # openly presentation-side.
32
+ #
33
+ # ## The ratchet
34
+ # Once page code queries policies by purpose, add any role-shaped
35
+ # getters your policies DO export (`user_is_super_admin?`, ...) to
36
+ # RolePredicates. Policies are excluded paths, so policy-internal use
37
+ # stays free while new loose call sites in page code flag mechanically.
38
+ #
39
+ # ## Configuration
40
+ # `RolePredicates` is a closed, per-project list — add every role
41
+ # predicate your User/Member models define:
42
+ #
43
+ # DevDoc/Auth/RolePredicateOutsidePolicy:
44
+ # RolePredicates:
45
+ # - admin?
46
+ # - super_admin?
47
+ # - effective_admin_of_organization?
48
+ # - explicit_admin_of_organization?
49
+ #
50
+ # ## Allowed paths (Exclude:)
51
+ # By default the cop is silent in:
52
+ # app/policies/**/*.rb ← where the predicates belong
53
+ # app/models/**/*.rb ← where they are defined/composed
54
+ # app/helpers/**/*.rb ← sanctioned display branching
55
+ # app/views/layouts/**/* ← sanctioned display branching
56
+ # test/, spec/ ← tests assert role facts as fixture
57
+ # preconditions; they verify the permission
58
+ # model rather than fragment it
59
+ #
60
+ # @example
61
+ # # bad — controller decides a permission by role
62
+ # if current_user.admin?
63
+ # auto_approve
64
+ # end
65
+ #
66
+ # # bad — the alias dodge is equally visible
67
+ # if @user.admin?
68
+ # auto_approve
69
+ # end
70
+ #
71
+ # # good — the policy owns the decision; page code queries it
72
+ # if policy(@organization).auto_approve?
73
+ # auto_approve
74
+ # end
75
+ class RolePredicateOutsidePolicy < Base
76
+ MSG = '`%<method>s` is a role predicate — an authorization question that belongs in ' \
77
+ 'the Pundit policy. Move the DECISION there and query it by purpose ' \
78
+ '(`policy(record).auto_approve?`) — name the query after the decision it ' \
79
+ 'guards, never after the role (`user_is_admin?` just re-exports the ' \
80
+ 'scattering through the policy door, and belongs in this cop\'s ' \
81
+ 'RolePredicates list). For pure display branching (badges, admin hints), ' \
82
+ 'use a helper. Renaming the receiver never exempts it: the flagged thing ' \
83
+ 'is the question, not who is asked.'.freeze
84
+
85
+ def on_send(node)
86
+ method_name = node.method_name
87
+ return unless role_predicates.include?(method_name)
88
+
89
+ add_offense(node.loc.selector, message: format(MSG, method: method_name))
90
+ end
91
+ alias on_csend on_send
92
+
93
+ private
94
+
95
+ def role_predicates
96
+ @role_predicates ||= (cop_config['RolePredicates'] || []).map(&:to_sym)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -40,9 +40,7 @@ module RuboCop
40
40
  TRANSLATION_METHODS = %i[t translate].freeze
41
41
 
42
42
  def on_send(node)
43
- if watched_methods.include?(node.method_name.to_s)
44
- node.arguments.each { |arg| each_localizable(arg) }
45
- end
43
+ node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
46
44
 
47
45
  check_jbuilder_positional(node)
48
46
  end
@@ -56,19 +54,22 @@ module RuboCop
56
54
  def each_localizable(node)
57
55
  case node.type
58
56
  when :hash
59
- node.pairs.each do |pair|
60
- key = pair.key
61
- if key.sym_type? && localizable_keys.include?(key.value.to_s)
62
- inspect_localizable(key.value, pair.value)
63
- end
64
-
65
- each_localizable(pair.value)
66
- end
57
+ node.pairs.each { |pair| walk_pair(pair) }
67
58
  when :array
68
59
  node.children.each { |child| each_localizable(child) }
60
+ else
61
+ # Any other node type (literal, send, variable…) can't contain
62
+ # localizable key-value pairs.
69
63
  end
70
64
  end
71
65
 
66
+ def walk_pair(pair)
67
+ key = pair.key
68
+ inspect_localizable(key.value, pair.value) if key.sym_type? && localizable_keys.include?(key.value.to_s)
69
+
70
+ each_localizable(pair.value)
71
+ end
72
+
72
73
  # jbuilder positional form: `json.title 'Forms'`. The method name is
73
74
  # the key and the first argument is the value. Restricted to the
74
75
  # jbuilder root `json` so ordinary `obj.text('...')` calls aren't
@@ -74,6 +74,9 @@ module RuboCop
74
74
  when :dstr
75
75
  first = node.children.first
76
76
  first&.str_type? ? first.value : nil
77
+ else
78
+ # Fully dynamic key (variable, send…) — nil is this method's
79
+ # documented "statically unknown" return.
77
80
  end
78
81
  end
79
82
 
@@ -62,6 +62,9 @@ module RuboCop
62
62
  return if name.end_with?('_at')
63
63
 
64
64
  add_offense(col_name_node, message: format(DATETIME_MSG, name: name))
65
+ else
66
+ # add_column funnels every column type here; only date/time
67
+ # types carry a suffix naming rule.
65
68
  end
66
69
  end
67
70
  end
@@ -125,8 +125,9 @@ module RuboCop
125
125
  private
126
126
 
127
127
  def receiver_shape_matches?(node)
128
- return node.receiver&.const_type? if CONST_RECEIVER_ONLY.include?(node.method_name)
129
- return !node.receiver&.const_type? if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
128
+ const_receiver = node.receiver&.const_type?
129
+ return const_receiver if CONST_RECEIVER_ONLY.include?(node.method_name)
130
+ return !const_receiver if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
130
131
 
131
132
  true
132
133
  end
@@ -44,8 +44,8 @@ module RuboCop
44
44
  # run — something `validate` can't express), disable this cop inline
45
45
  # with a written justification:
46
46
  #
47
- # def run_validations! # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride
48
- # # Reason: <explanation>
47
+ # # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride -- <explanation>
48
+ # def run_validations!
49
49
  # with_access { super }
50
50
  # end
51
51
  #
@@ -86,8 +86,8 @@ module RuboCop
86
86
  # genuine exception, disable this cop inline with a written
87
87
  # justification — expect pushback in review:
88
88
  #
89
- # after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
90
- # # Reason: <explanation>
89
+ # # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks -- <explanation>
90
+ # after_create :some_callback
91
91
  #
92
92
  # Both the symbol form and the block form are flagged:
93
93
  #
@@ -136,7 +136,7 @@ module RuboCop
136
136
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
137
137
  end
138
138
 
139
- def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
139
+ def on_block(node)
140
140
  send_node = node.send_node
141
141
  return unless CALLBACKS.include?(send_node.method_name)
142
142
 
@@ -86,7 +86,9 @@ module RuboCop
86
86
 
87
87
  def enum_column(klass, name)
88
88
  table = schema.table_by(name: table_name(klass))
89
- table&.columns&.find { |c| c.name == name.to_s }
89
+ return nil unless table
90
+
91
+ table.columns.find { |c| c.name == name.to_s }
90
92
  end
91
93
 
92
94
  def class_node(node)
@@ -121,7 +121,10 @@ module RuboCop
121
121
  return explicit.method_name.to_s if explicit
122
122
 
123
123
  # Otherwise the nearest block that isn't a transparent wrapper decides.
124
- decider = sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) }
124
+ decider_context(sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) })
125
+ end
126
+
127
+ def decider_context(decider)
125
128
  return unless decider
126
129
 
127
130
  return 'collection' if RESOURCEFUL.include?(decider.method_name)
@@ -48,13 +48,14 @@ module RuboCop
48
48
  expected = expected_name(node.method_name, name)
49
49
  next if expected == name
50
50
 
51
- add_offense(
52
- arg,
53
- message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
54
- )
51
+ add_offense(arg, message: offense_message(node.method_name, expected))
55
52
  end
56
53
  end
57
54
 
55
+ def offense_message(method_name, expected)
56
+ format(MSG, method: method_name, number: number_word(method_name), expected: expected)
57
+ end
58
+
58
59
  private
59
60
 
60
61
  # The resource name as a string, for symbol or string arguments only
@@ -43,14 +43,13 @@ module RuboCop
43
43
  RESTRICT_ON_SEND = %i[resources resource].freeze
44
44
 
45
45
  def on_send(node)
46
- has_only = key_present?(node, :only)
47
- has_except = key_present?(node, :except)
46
+ return if key_present?(node, :only)
48
47
 
49
- return if has_only
48
+ has_except = key_present?(node, :except)
50
49
  return if has_except && !require_only?
51
50
 
52
51
  name = node.first_argument&.value || '?'
53
- msg = has_except && require_only? ? MSG_REQUIRE_ONLY : MSG
52
+ msg = has_except ? MSG_REQUIRE_ONLY : MSG
54
53
  add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
55
54
  end
56
55
 
@@ -0,0 +1,116 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `.to_sym` (including `&.to_sym`, and through laundering hops
6
+ # like `.presence`/`.to_s`/`.downcase`) chained directly onto an
7
+ # attacker-controlled boundary source: `params[...]` (also `.dig`,
8
+ # `.fetch`, `.require`) or `request.headers[...]`.
9
+ #
10
+ # ## Rationale
11
+ # A bare `.to_sym` lets ANY client-supplied value cross into the symbol
12
+ # domain, where downstream `case`/`==` dispatch treats it as known
13
+ # vocabulary. The allow-list is the missing DECISION: which values do
14
+ # we recognise, and what happens to the rest? Skipping it also mints a
15
+ # symbol per novel value (the classic symbol-DoS vector; GC reclaims
16
+ # dynamic symbols since Ruby 2.2, so today this is churn rather than a
17
+ # hard leak — the vocabulary laundering is the real bug).
18
+ #
19
+ # Resolve the value through the glib-web helper, which only ever
20
+ # returns a symbol already in the allow-list:
21
+ #
22
+ # ❌
23
+ # @mode = params[:mode]&.to_sym || :active
24
+ #
25
+ # ✔️ (glib-web >= 5.1.2)
26
+ # @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
27
+ #
28
+ # When recognised inputs must map to differently-spelled symbols
29
+ # (e.g. `'spring-promo'` → `:spring_promo`), use an explicit
30
+ # `case`-when at the boundary instead — same allow-list property.
31
+ #
32
+ # ## Sources detected
33
+ # - `params[…]`, `params.dig(…)`, `params.fetch(…)`, `params.require(…)`
34
+ # - `request.headers[…]`
35
+ #
36
+ # `ENV[…]` is deliberately NOT a source here (unlike the sibling
37
+ # `DevDoc/Style/StringSymbolComparison`): ENV is operator-controlled,
38
+ # not attacker-reachable, and `.to_sym` on boot-time config is
39
+ # legitimate where the controller helper doesn't even exist.
40
+ #
41
+ # ## Relationship with DevDoc/Style/StringSymbolComparison
42
+ # Complementary pair over the same boundary doctrine: that cop flags
43
+ # comparing an UNCONVERTED string source against a symbol (always
44
+ # false); this cop flags CONVERTING without validating. The sanctioned
45
+ # exit from both is the allow-list helper.
46
+ #
47
+ # ## Limitations
48
+ # Only the direct form is caught. Once the value flows through a
49
+ # helper method (`report_params[:x]`) or a local/instance variable,
50
+ # static analysis loses the boundary and review has to catch it.
51
+ # Laundering transforms (`.presence`, `.to_s`, `.strip`,
52
+ # `.downcase`, `.upcase`, `.squish`) do NOT reset the trail — they
53
+ # transform, they don't validate.
54
+ #
55
+ # @example
56
+ # # bad
57
+ # @mode = params[:mode]&.to_sym || :active
58
+ # @theme = request.headers['X-Theme']&.to_sym
59
+ # @segment = params[:segment].presence&.to_sym
60
+ # @sort = params[:sort].to_s.downcase.to_sym
61
+ #
62
+ # # good
63
+ # @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
64
+ class AvoidSymbolizingBoundaryInput < Base
65
+ MSG = 'Unvalidated `.to_sym` on `%<source>s` lets any client value into the symbol ' \
66
+ 'domain (and mints a symbol per novel value). Allow-list it instead: ' \
67
+ '`glib_allowlist_symbol_param(value, ALLOWED, default:)`. Laundering through ' \
68
+ '`.to_s`/`.presence`/`.downcase` is not validation.'.freeze
69
+
70
+ RESTRICT_ON_SEND = %i[to_sym].freeze
71
+
72
+ # String-preserving transforms that commonly sit between the boundary
73
+ # read and the `.to_sym` — they launder the value, not validate it,
74
+ # so the chain is still an offense.
75
+ TRANSFORM_HOPS = %i[presence to_s strip downcase upcase squish].freeze
76
+
77
+ def_node_matcher :params_access?, <<~PATTERN
78
+ (send (send nil? :params) {:[] :dig :fetch :require} ...)
79
+ PATTERN
80
+
81
+ def_node_matcher :request_headers_access?, <<~PATTERN
82
+ (send (send _ :headers) :[] _)
83
+ PATTERN
84
+
85
+ def on_send(node)
86
+ source = boundary_source_in(node.receiver)
87
+ return unless source
88
+
89
+ add_offense(node, message: format(MSG, source: source.source))
90
+ end
91
+ alias on_csend on_send
92
+
93
+ private
94
+
95
+ # The boundary source at the bottom of the receiver chain, walking
96
+ # through any number of laundering transforms. Returns nil for
97
+ # anything else (locals, `action_name`, helper-method results) so
98
+ # those don't false-positive.
99
+ def boundary_source_in(node)
100
+ while node
101
+ return node if boundary_source?(node)
102
+ return nil unless node.send_type? || node.csend_type?
103
+ return nil unless TRANSFORM_HOPS.include?(node.method_name)
104
+
105
+ node = node.receiver
106
+ end
107
+ end
108
+
109
+ def boundary_source?(node)
110
+ params_access?(node) || request_headers_access?(node)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -15,7 +15,8 @@ module RuboCop
15
15
  #
16
16
  # 1. **Fall-through genuinely happens in normal operation** — add
17
17
  # `else` with a comment stating WHY it is correct (requires
18
- # `Style/EmptyElse` to be disabled).
18
+ # `Style/EmptyElse` with `AllowComments: true`, which keeps that
19
+ # cop's guard against comment-less empty elses).
19
20
  # 2. **A non-match is a bug and continuing is unsafe** — `else raise`
20
21
  # (fail fast; unexpected enum/mode values must not proceed).
21
22
  # 3. **A non-match is a bug but users can safely continue** — report
@@ -83,21 +83,28 @@ module RuboCop
83
83
 
84
84
  siblings = parent.children
85
85
  nxt = siblings[siblings.index(asgn) + 1]
86
- nxt if nxt&.if_type? && !nxt.ternary? && !nxt.modifier_form? && nxt.if?
86
+ nxt if plain_if?(nxt)
87
+ end
88
+
89
+ # A statement-form `if` (not a ternary, modifier, or unless).
90
+ def plain_if?(node)
91
+ node&.if_type? && !node.ternary? && !node.modifier_form? && node.if?
87
92
  end
88
93
 
89
94
  # `if x` or `if x.present?` — a truthiness check on the assigned var.
90
95
  def truthiness_check?(condition, name)
91
96
  return false unless condition
97
+ return condition.children.first == name if condition.lvar_type?
92
98
 
93
- if condition.lvar_type?
94
- condition.children.first == name
95
- elsif condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
96
- recv = condition.receiver
97
- recv&.lvar_type? && recv.children.first == name
98
- else
99
- false
100
- end
99
+ truthy_predicate_on?(condition, name)
100
+ end
101
+
102
+ # `x.present?`-style: a truthy predicate whose receiver is the var.
103
+ def truthy_predicate_on?(condition, name)
104
+ return false unless condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
105
+
106
+ recv = condition.receiver
107
+ recv&.lvar_type? && recv.children.first == name
101
108
  end
102
109
 
103
110
  # Every read of `name` in the enclosing scope sits inside the if's
@@ -109,12 +116,15 @@ module RuboCop
109
116
  return false unless single_plain_assignment?(scope, name, asgn)
110
117
  return false if rebinds_name?(scope, name)
111
118
 
119
+ reads_confined_to?(if_node, reads(scope, name))
120
+ end
121
+
122
+ # Read at least once in the true branch, and every read sits inside
123
+ # the if's condition or that branch.
124
+ def reads_confined_to?(if_node, refs)
112
125
  true_branch = if_node.if_branch
113
126
  return false unless true_branch
114
-
115
- refs = reads(scope, name)
116
- in_branch = refs.select { |r| within?(r, true_branch) }
117
- return false if in_branch.empty?
127
+ return false if refs.none? { |r| within?(r, true_branch) }
118
128
 
119
129
  refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
120
130
  end