keeper_secrets_manager 17.0.4 → 17.2.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -16
  3. data/Gemfile +6 -3
  4. data/README.md +75 -270
  5. data/Rakefile +1 -1
  6. data/bin/console +47 -0
  7. data/keeper_secrets_manager.gemspec +36 -0
  8. data/lib/keeper_secrets_manager/cache.rb +139 -0
  9. data/lib/keeper_secrets_manager/config_keys.rb +4 -2
  10. data/lib/keeper_secrets_manager/core.rb +993 -452
  11. data/lib/keeper_secrets_manager/crypto.rb +101 -116
  12. data/lib/keeper_secrets_manager/dto/payload.rb +7 -6
  13. data/lib/keeper_secrets_manager/dto.rb +374 -38
  14. data/lib/keeper_secrets_manager/errors.rb +13 -2
  15. data/lib/keeper_secrets_manager/field_types.rb +3 -3
  16. data/lib/keeper_secrets_manager/folder_manager.rb +25 -29
  17. data/lib/keeper_secrets_manager/keeper_globals.rb +11 -17
  18. data/lib/keeper_secrets_manager/notation.rb +202 -93
  19. data/lib/keeper_secrets_manager/notation_enhancements.rb +24 -24
  20. data/lib/keeper_secrets_manager/storage.rb +38 -38
  21. data/lib/keeper_secrets_manager/totp.rb +27 -27
  22. data/lib/keeper_secrets_manager/utils.rb +85 -18
  23. data/lib/keeper_secrets_manager/version.rb +2 -2
  24. data/lib/keeper_secrets_manager.rb +11 -3
  25. metadata +39 -22
  26. data/DEVELOPER_SETUP.md +0 -0
  27. data/MANUAL_TESTING_GUIDE.md +0 -332
  28. data/RUBY_SDK_COMPLETE_DOCUMENTATION.md +0 -354
  29. data/RUBY_SDK_COMPREHENSIVE_SUMMARY.md +0 -192
  30. data/examples/01_quick_start.rb +0 -45
  31. data/examples/02_authentication.rb +0 -82
  32. data/examples/03_retrieve_secrets.rb +0 -81
  33. data/examples/04_create_update_delete.rb +0 -104
  34. data/examples/05_field_types.rb +0 -135
  35. data/examples/06_files.rb +0 -137
  36. data/examples/07_folders.rb +0 -145
  37. data/examples/08_notation.rb +0 -103
  38. data/examples/09_totp.rb +0 -100
  39. data/examples/README.md +0 -89
@@ -3,72 +3,70 @@ module KeeperSecretsManager
3
3
  def initialize(folders)
4
4
  @folders = folders
5
5
  end
6
-
6
+
7
7
  # Build a hierarchical tree structure from flat folder list
8
8
  def build_folder_tree
9
9
  # Create a hash for quick lookup
10
10
  folder_map = {}
11
11
  @folders.each { |f| folder_map[f.uid] = f }
12
-
12
+
13
13
  # Find root folders (no parent) and build tree
14
14
  root_folders = []
15
15
  @folders.each do |folder|
16
- if folder.parent_uid.nil? || folder.parent_uid.empty?
17
- root_folders << build_node(folder, folder_map)
18
- end
16
+ root_folders << build_node(folder, folder_map) if folder.parent_uid.nil? || folder.parent_uid.empty?
19
17
  end
20
-
18
+
21
19
  root_folders
22
20
  end
23
-
21
+
24
22
  # Get folder path from root to given folder
25
23
  def get_folder_path(folder_uid)
26
24
  folder = @folders.find { |f| f.uid == folder_uid }
27
25
  return nil unless folder
28
-
26
+
29
27
  path = []
30
28
  current = folder
31
-
29
+
32
30
  # Walk up the tree
33
31
  while current
34
32
  path.unshift(current.name)
35
33
  current = @folders.find { |f| f.uid == current.parent_uid }
36
34
  end
37
-
35
+
38
36
  path.join('/')
39
37
  end
40
-
38
+
41
39
  # Get all ancestors of a folder (parent, grandparent, etc.)
42
40
  def get_ancestors(folder_uid)
43
41
  ancestors = []
44
42
  folder = @folders.find { |f| f.uid == folder_uid }
45
43
  return ancestors unless folder
46
-
44
+
47
45
  current_parent_uid = folder.parent_uid
48
46
  while current_parent_uid && !current_parent_uid.empty?
49
47
  parent = @folders.find { |f| f.uid == current_parent_uid }
50
48
  break unless parent
51
-
49
+
52
50
  ancestors << parent
53
51
  current_parent_uid = parent.parent_uid
54
52
  end
55
-
53
+
56
54
  ancestors
57
55
  end
58
-
56
+
59
57
  # Get all descendants of a folder (children, grandchildren, etc.)
60
58
  def get_descendants(folder_uid)
61
59
  descendants = []
62
60
  children = @folders.select { |f| f.parent_uid == folder_uid }
63
-
61
+
64
62
  children.each do |child|
65
63
  descendants << child
66
64
  descendants.concat(get_descendants(child.uid))
67
65
  end
68
-
66
+
69
67
  descendants
70
68
  end
71
-
69
+
72
70
  # Find folder by name (optionally within a parent)
73
71
  def find_folder_by_name(name, parent_uid: nil)
74
72
  if parent_uid
@@ -77,11 +75,11 @@ module KeeperSecretsManager
77
75
  @folders.find { |f| f.name == name }
78
76
  end
79
77
  end
80
-
78
+
81
79
  # Print folder tree to console
82
80
  def print_tree(folders = nil, indent = 0)
83
81
  folders ||= build_folder_tree
84
-
82
+
85
83
  folders.each do |node|
86
84
  puts "#{' ' * indent}├── #{node[:folder].name} (#{node[:folder].uid})"
87
85
  if node[:folder].records && !node[:folder].records.empty?
@@ -92,23 +90,21 @@ module KeeperSecretsManager
92
90
  print_tree(node[:children], indent + 4) if node[:children]
93
91
  end
94
92
  end
95
-
93
+
96
94
  private
97
-
95
+
98
96
  def build_node(folder, folder_map)
99
- node = {
97
+ node = {
100
98
  folder: folder,
101
99
  children: []
102
100
  }
103
-
101
+
104
102
  # Find children
105
103
  @folders.each do |f|
106
- if f.parent_uid == folder.uid
107
- node[:children] << build_node(f, folder_map)
108
- end
104
+ node[:children] << build_node(f, folder_map) if f.parent_uid == folder.uid
109
105
  end
110
-
106
+
111
107
  node
112
108
  end
113
109
  end
114
- end
110
+ end
@@ -2,20 +2,12 @@ require_relative 'version'
2
2
 
3
3
  module KeeperSecretsManager
4
4
  module KeeperGlobals
5
- # Client version prefix
6
- # **NOTE: 'mb' client version is NOT YET REGISTERED with Keeper servers!**
7
- # **TODO: Register 'mb' (Ruby) client with Keeper before production use**
8
- # **Currently using 'mr' which is already registered (likely Rust SDK)**
9
- CLIENT_VERSION_PREFIX = 'mr'.freeze # Should be 'mb' for Ruby, but using 'mr' temporarily
10
-
11
- # Get client version
5
+ CLIENT_VERSION_PREFIX = 'mb'.freeze
6
+
12
7
  def self.client_version
13
- # Use standard version format matching other SDKs
14
- # Java: mj17.0.0, Python: mp16.x.x, JavaScript: ms16.x.x, Go: mg16.x.x
15
- # Ruby should be: mb17.0.0 (but not registered yet)
16
- "#{CLIENT_VERSION_PREFIX}17.0.0"
8
+ "#{CLIENT_VERSION_PREFIX}#{KeeperSecretsManager::VERSION}"
17
9
  end
18
-
10
+
19
11
  # Keeper public keys by ID
20
12
  KEEPER_PUBLIC_KEYS = {
21
13
  '1' => 'BK9w6TZFxE6nFNbMfIpULCup2a8xc6w2tUTABjxny7yFmxW0dAEojwC6j6zb5nTlmb1dAx8nwo3qF7RPYGmloRM',
@@ -34,7 +26,8 @@ module KeeperSecretsManager
34
26
  '14' => 'BJFF8j-dH7pDEw_U347w2CBM6xYM8Dk5fPPAktjib-opOqzvvbsER-WDHM4ONCSBf9O_obAHzCyygxmtpktDuiE',
35
27
  '15' => 'BDKyWBvLbyZ-jMueORl3JwJnnEpCiZdN7yUvT0vOyjwpPBCDf6zfL4RWzvSkhAAFnwOni_1tQSl8dfXHbXqXsQ8',
36
28
  '16' => 'BDXyZZnrl0tc2jdC5I61JjwkjK2kr7uet9tZjt8StTiJTAQQmnVOYBgbtP08PWDbecxnHghx3kJ8QXq1XE68y8c',
37
- '17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU'
29
+ '17' => 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU',
30
+ '18' => 'BNhngQqTT1bPKxGuB6FhbPTAeNVFl8PKGGSGo5W06xWIReutm6ix6JPivqnbvkydY-1uDQTr-5e6t70G01Bb5JA'
38
31
  }.freeze
39
32
 
40
33
  # Keeper servers by region
@@ -44,16 +37,17 @@ module KeeperSecretsManager
44
37
  'AU' => 'keepersecurity.com.au',
45
38
  'GOV' => 'govcloud.keepersecurity.us',
46
39
  'JP' => 'keepersecurity.jp',
47
- 'CA' => 'keepersecurity.ca'
40
+ 'CA' => 'keepersecurity.ca',
41
+ 'IL5' => 'il5.keepersecurity.us'
48
42
  }.freeze
49
43
 
50
44
  # Default server (US)
51
45
  DEFAULT_SERVER = KEEPER_SERVERS['US'].freeze
52
-
46
+
53
47
  # Default public key ID
54
48
  DEFAULT_KEY_ID = '7'.freeze
55
-
49
+
56
50
  # Logger name
57
51
  LOGGER_NAME = 'keeper_secrets_manager'.freeze
58
52
  end
59
- end
53
+ end
@@ -11,42 +11,93 @@ module KeeperSecretsManager
11
11
  @secrets_manager = secrets_manager
12
12
  end
13
13
 
14
+ def get_notation_results(notation)
15
+ return [] if notation.nil? || !notation.is_a?(String) || notation.empty?
16
+
17
+ parsed = parse_notation(notation)
18
+ raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
19
+
20
+ record_token = parsed[1].text&.first
21
+ selector = parsed[2].text&.first
22
+ raise NotationError, 'Invalid notation: missing record' unless record_token
23
+ raise NotationError, 'Invalid notation: missing selector' unless selector
24
+
25
+ records = @secrets_manager.get_secrets([record_token])
26
+ if records.empty?
27
+ all = @secrets_manager.get_secrets
28
+ records = all.select { |r| r.title == record_token }
29
+ end
30
+ records = records.uniq { |r| r.uid } if records.size > 1
31
+ raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
32
+ raise NotationError, "No records match '#{record_token}'" if records.empty?
33
+
34
+ record = records.first
35
+ parameter = parsed[2].parameter&.first
36
+ index1 = parsed[2].index1&.first
37
+ index2 = parsed[2].index2&.first
38
+
39
+ case selector.downcase
40
+ when 'type'
41
+ record.type ? [record.type] : []
42
+ when 'title'
43
+ record.title ? [record.title] : []
44
+ when 'notes'
45
+ (record.notes && !record.notes.empty?) ? [record.notes] : []
46
+ when 'file'
47
+ notation_results_file(record, parameter, record_token)
48
+ when 'field', 'custom_field'
49
+ notation_results_field(record, parameter, index1, index2, parsed[2])
50
+ else
51
+ raise NotationError, "Invalid selector: #{selector}"
52
+ end
53
+ end
54
+
14
55
  # Parse notation and return value
15
56
  def parse(notation)
16
- return nil if notation.nil? || notation.empty?
17
-
57
+ return nil if notation.nil?
58
+ raise NotationError, 'Invalid notation format: must be a string' unless notation.is_a?(String)
59
+ return nil if notation.empty?
60
+
18
61
  # Parse notation URI
19
- parsed = parse_notation(notation)
20
-
62
+ begin
63
+ parsed = parse_notation(notation)
64
+ rescue StandardError => e
65
+ raise NotationError, "Invalid notation format: #{e.message}"
66
+ end
67
+
21
68
  # Validate we have minimum required sections
22
69
  raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
23
-
70
+
24
71
  # Extract components
25
72
  record_token = parsed[1].text&.first
26
73
  selector = parsed[2].text&.first
27
-
28
- raise NotationError, "Invalid notation: missing record" unless record_token
29
- raise NotationError, "Invalid notation: missing selector" unless selector
30
-
74
+
75
+ raise NotationError, 'Invalid notation: missing record' unless record_token
76
+ raise NotationError, 'Invalid notation: missing selector' unless selector
77
+
31
78
  # Get record
32
79
  records = @secrets_manager.get_secrets([record_token])
33
-
80
+
34
81
  # If not found by UID, try by title
35
82
  if records.empty?
36
83
  all_records = @secrets_manager.get_secrets
37
84
  records = all_records.select { |r| r.title == record_token }
38
85
  end
39
-
86
+
87
+ # Remove duplicate UIDs - shortcuts/linked records both shared to same KSM App
88
+ records = records.uniq { |r| r.uid } if records.size > 1
89
+
90
+ # Now check for genuine ambiguity (different records with same title)
40
91
  raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
41
92
  raise NotationError, "No records match '#{record_token}'" if records.empty?
42
-
93
+
43
94
  record = records.first
44
-
95
+
45
96
  # Extract parameters
46
97
  parameter = parsed[2].parameter&.first
47
98
  index1 = parsed[2].index1&.first
48
99
  index2 = parsed[2].index2&.first
49
-
100
+
50
101
  # Process selector
51
102
  case selector.downcase
52
103
  when 'type'
@@ -66,72 +117,135 @@ module KeeperSecretsManager
66
117
 
67
118
  private
68
119
 
120
+ def value_to_string(v)
121
+ v.is_a?(String) ? v : v.to_json
122
+ end
123
+
124
+ # Unlike handle_field_selector: no first-element shortcut when no index is given.
125
+ def notation_results_field(record, parameter, index1, index2, parsed_section)
126
+ raise NotationError, 'Missing required parameter for field' unless parameter
127
+
128
+ field = record.get_field(parameter)
129
+ raise NotationError, "Field '#{parameter}' not found" unless field
130
+
131
+ values = field['value'] || []
132
+ idx = parse_index(index1)
133
+
134
+ if idx == -1 && index1 && !index1.empty?
135
+ # index1 is a property name (e.g. [hostName])
136
+ if values.first.is_a?(Hash)
137
+ raise NotationError, "Property '#{index1}' not found" unless values.first.key?(index1)
138
+
139
+ return [value_to_string(values.first[index1])]
140
+ else
141
+ raise NotationError, 'Cannot extract property from non-object value'
142
+ end
143
+ end
144
+
145
+ raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx != -1 && idx >= values.size
146
+
147
+ selected = idx >= 0 ? [values[idx]] : values
148
+
149
+ if index2 && !index2.empty? && index2 != '[]'
150
+ selected.filter_map do |v|
151
+ next nil unless v.is_a?(Hash) && v.key?(index2)
152
+
153
+ value_to_string(v[index2])
154
+ end
155
+ else
156
+ selected.map { |v| value_to_string(v) }
157
+ end
158
+ end
159
+
160
+ def notation_results_file(record, parameter, record_token)
161
+ file = handle_file_selector(record, parameter, record_token)
162
+
163
+ file_hash = if file.is_a?(KeeperSecretsManager::Dto::KeeperFile)
164
+ {
165
+ 'fileUid' => file.uid,
166
+ 'url' => file.url,
167
+ 'fileKey' => file.file_key,
168
+ 'name' => file.name
169
+ }
170
+ else
171
+ file
172
+ end
173
+
174
+ content = @secrets_manager.download_file(file_hash)
175
+ [KeeperSecretsManager::Utils.bytes_to_url_safe_str(content['data'])]
176
+ end
177
+
69
178
  # Handle file selector
70
179
  def handle_file_selector(record, parameter, record_token)
71
- raise NotationError, "Missing required parameter: filename or file UID" unless parameter
72
- raise NotationError, "Record #{record_token} has no file attachments" if record.files.nil? || record.files.empty?
73
-
180
+ raise NotationError, 'Missing required parameter: filename or file UID' unless parameter
181
+
182
+ if record.files.nil? || record.files.empty?
183
+ raise NotationError,
184
+ "Record #{record_token} has no file attachments"
185
+ end
186
+
74
187
  # Find matching file
75
188
  files = record.files.select do |f|
76
189
  parameter == f.name || parameter == f.title || parameter == f.uid
77
190
  end
78
-
191
+
79
192
  raise NotationError, "No files match '#{parameter}'" if files.empty?
80
193
  raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
81
-
194
+
82
195
  # Return file object (downloading would be handled by the caller)
83
196
  files.first
84
197
  end
85
198
 
86
199
  # Handle field selector
87
200
  def handle_field_selector(record, selector, parameter, index1, index2, parsed_section)
88
- raise NotationError, "Missing required parameter for field" unless parameter
89
-
90
- # Get field array
91
- custom_field = selector.downcase == 'custom_field'
92
- field = record.get_field(parameter, custom_field)
93
-
201
+ raise NotationError, 'Missing required parameter for field' unless parameter
202
+
203
+ # Get field (works for both standard and custom fields)
204
+ field = record.get_field(parameter)
205
+
94
206
  raise NotationError, "Field '#{parameter}' not found" unless field
95
-
207
+
96
208
  # Get field values
97
209
  values = field['value'] || []
98
-
210
+
99
211
  # Handle index1
100
212
  idx = parse_index(index1)
101
-
102
- # Validate index1 - only raise error if we have a non-empty, non-bracket value
103
- if idx == -1 && parsed_section.index1 &&
104
- parsed_section.index1[1] != '' && parsed_section.index1[1] != '[]' &&
105
- parsed_section.index1[0] != '' # Empty string parses to -1 which is valid
106
- raise NotationError, "Invalid field index: #{parsed_section.index1[0]}"
107
- end
108
-
109
- if idx >= values.size
110
- raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}"
213
+
214
+ # If index1 is not a valid number but has a value, treat it as a property name
215
+ if idx == -1 && index1 && !index1.empty?
216
+ # index1 is a property name (e.g., [hostName])
217
+ if values.first.is_a?(Hash)
218
+ property = index1
219
+ if values.first.key?(property)
220
+ return values.first[property]
221
+ else
222
+ raise NotationError, "Property '#{property}' not found"
223
+ end
224
+ else
225
+ raise NotationError, 'Cannot extract property from non-object value'
226
+ end
111
227
  end
112
-
228
+
229
+ raise NotationError, "Field index out of bounds: #{idx} >= #{values.size}" if idx >= values.size
230
+
113
231
  # Apply index1
114
232
  values = [values[idx]] if idx >= 0
115
-
233
+
116
234
  # Handle legacy compatibility
117
- if parsed_section.index1.nil? && parsed_section.index2.nil?
118
- return values.first
119
- end
120
-
121
- if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
235
+ return values.first if parsed_section.index1.nil? && parsed_section.index2.nil?
236
+
237
+ if parsed_section.index1 && parsed_section.index1[1] == '[]' &&
122
238
  (index2.nil? || index2.empty?)
123
239
  return values
124
240
  end
125
-
126
- if index1.to_s.empty? && !index2.to_s.empty?
127
- return values.first[index2] if values.first.is_a?(Hash)
128
- end
129
-
241
+
242
+ return values.first[index2] if index1.to_s.empty? && !index2.to_s.empty? && values.first.is_a?(Hash)
243
+
130
244
  # Handle index2 (property access)
131
- full_obj_value = parsed_section.index2.nil? ||
132
- parsed_section.index2[1] == '' ||
133
- parsed_section.index2[1] == '[]'
134
-
245
+ full_obj_value = parsed_section.index2.nil? ||
246
+ parsed_section.index2[1] == '' ||
247
+ parsed_section.index2[1] == '[]'
248
+
135
249
  if full_obj_value
136
250
  idx >= 0 ? values.first : values
137
251
  elsif values.first.is_a?(Hash)
@@ -142,14 +256,14 @@ module KeeperSecretsManager
142
256
  raise NotationError, "Property '#{obj_property}' not found"
143
257
  end
144
258
  else
145
- raise NotationError, "Cannot extract property from non-object value"
259
+ raise NotationError, 'Cannot extract property from non-object value'
146
260
  end
147
261
  end
148
262
 
149
263
  # Parse index value
150
264
  def parse_index(index_str)
151
265
  return -1 if index_str.nil? || index_str.empty?
152
-
266
+
153
267
  begin
154
268
  Integer(index_str)
155
269
  rescue ArgumentError
@@ -164,23 +278,23 @@ module KeeperSecretsManager
164
278
  begin
165
279
  decoded = Base64.urlsafe_decode64(notation)
166
280
  notation = decoded.force_encoding('UTF-8')
167
- rescue
168
- raise NotationError, "Invalid notation format"
281
+ rescue StandardError
282
+ raise NotationError, 'Invalid notation format'
169
283
  end
170
284
  end
171
-
285
+
172
286
  # Parse sections
173
287
  prefix = parse_section(notation, 'prefix', 0)
174
288
  pos = prefix.present? ? prefix.end_pos + 1 : 0
175
-
289
+
176
290
  record = parse_section(notation, 'record', pos)
177
291
  pos = record.present? ? record.end_pos + 1 : notation.length
178
-
292
+
179
293
  selector = parse_section(notation, 'selector', pos)
180
294
  pos = selector.present? ? selector.end_pos + 1 : notation.length
181
-
295
+
182
296
  footer = parse_section(notation, 'footer', pos)
183
-
297
+
184
298
  [prefix, record, selector, footer]
185
299
  end
186
300
 
@@ -188,7 +302,7 @@ module KeeperSecretsManager
188
302
  def parse_section(notation, section_name, pos)
189
303
  result = NotationSection.new(section_name)
190
304
  result.start_pos = pos
191
-
305
+
192
306
  case section_name.downcase
193
307
  when 'prefix'
194
308
  # Check for keeper:// prefix
@@ -199,7 +313,7 @@ module KeeperSecretsManager
199
313
  result.end_pos = prefix.length - 1
200
314
  result.text = [notation[0...prefix.length], notation[0...prefix.length]]
201
315
  end
202
-
316
+
203
317
  when 'footer'
204
318
  # Footer is anything after the last section
205
319
  if pos < notation.length
@@ -208,7 +322,7 @@ module KeeperSecretsManager
208
322
  result.end_pos = notation.length - 1
209
323
  result.text = [notation[pos..], notation[pos..]]
210
324
  end
211
-
325
+
212
326
  when 'record'
213
327
  # Record is required - parse until '/' with escaping
214
328
  if pos < notation.length
@@ -220,7 +334,7 @@ module KeeperSecretsManager
220
334
  result.text = parsed
221
335
  end
222
336
  end
223
-
337
+
224
338
  when 'selector'
225
339
  # Selector is required
226
340
  if pos < notation.length
@@ -230,7 +344,7 @@ module KeeperSecretsManager
230
344
  result.start_pos = pos
231
345
  result.end_pos = pos + parsed[1].length - 1
232
346
  result.text = parsed
233
-
347
+
234
348
  # Check for long selectors that have parameters
235
349
  if %w[field custom_field file].include?(parsed[0].downcase)
236
350
  # Parse parameter (field type/label or filename)
@@ -240,13 +354,13 @@ module KeeperSecretsManager
240
354
  plen = param_parsed[1].length
241
355
  plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
242
356
  result.end_pos += plen
243
-
357
+
244
358
  # Parse index1 [N] or []
245
359
  index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
246
360
  if index1_parsed
247
361
  result.index1 = index1_parsed
248
362
  result.end_pos += index1_parsed[1].length
249
-
363
+
250
364
  # Parse index2 [property]
251
365
  index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
252
366
  if index2_parsed
@@ -258,80 +372,75 @@ module KeeperSecretsManager
258
372
  end
259
373
  end
260
374
  end
261
-
375
+
262
376
  else
263
377
  raise NotationError, "Unknown section: #{section_name}"
264
378
  end
265
-
379
+
266
380
  result
267
381
  end
268
382
 
269
383
  # Parse subsection with delimiters and escaping
270
384
  def parse_subsection(text, pos, delimiters, escaped = false)
271
385
  return nil if text.nil? || text.empty? || pos < 0 || pos >= text.length
272
-
273
- if delimiters.nil? || delimiters.length > 2
274
- raise NotationError, "Internal error: incorrect delimiters"
275
- end
276
-
386
+
387
+ raise NotationError, 'Internal error: incorrect delimiters' if delimiters.nil? || delimiters.length > 2
388
+
277
389
  token = ''
278
390
  raw = ''
279
-
391
+
280
392
  while pos < text.length
281
393
  if escaped && text[pos] == ESCAPE_CHAR
282
394
  # Handle escape sequence
283
395
  if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
284
396
  raise NotationError, "Incorrect escape sequence at position #{pos}"
285
397
  end
286
-
398
+
287
399
  token += text[pos + 1]
288
400
  raw += text[pos, 2]
289
401
  pos += 2
290
402
  else
291
403
  raw += text[pos]
292
-
404
+
293
405
  if delimiters.length == 1
294
406
  # Single delimiter
295
407
  break if text[pos] == delimiters[0]
408
+
296
409
  token += text[pos]
297
410
  else
298
411
  # Two delimiters (for brackets)
299
- if raw[0] != delimiters[0]
300
- raise NotationError, "Index sections must start with '['"
301
- end
302
-
412
+ raise NotationError, "Index sections must start with '['" if raw[0] != delimiters[0]
413
+
303
414
  if raw.length > 1 && text[pos] == delimiters[0]
304
415
  raise NotationError, "Index sections do not allow extra '[' inside"
305
416
  end
306
-
417
+
307
418
  if !delimiters.include?(text[pos])
308
419
  token += text[pos]
309
420
  elsif text[pos] == delimiters[1]
310
421
  break
311
422
  end
312
423
  end
313
-
424
+
314
425
  pos += 1
315
426
  end
316
427
  end
317
-
428
+
318
429
  # Validate brackets are properly closed
319
430
  if delimiters.length == 2
320
431
  if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
321
432
  raise NotationError, "Index sections must be enclosed in '[' and ']'"
322
433
  end
323
-
324
- if escaped && raw[-2] == ESCAPE_CHAR
325
- raise NotationError, "Index sections must be enclosed in '[' and ']'"
326
- end
434
+
435
+ raise NotationError, "Index sections must be enclosed in '[' and ']'" if escaped && raw[-2] == ESCAPE_CHAR
327
436
  end
328
-
437
+
329
438
  [token, raw]
330
439
  end
331
440
 
332
441
  # Notation section data class
333
442
  class NotationSection
334
- attr_accessor :section, :present, :start_pos, :end_pos,
443
+ attr_accessor :section, :present, :start_pos, :end_pos,
335
444
  :text, :parameter, :index1, :index2
336
445
 
337
446
  def initialize(section_name)
@@ -351,4 +460,4 @@ module KeeperSecretsManager
351
460
  end
352
461
  end
353
462
  end
354
- end
463
+ end