keeper_secrets_manager 17.0.3
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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/Rakefile +30 -0
- data/examples/basic_usage.rb +139 -0
- data/examples/config_string_example.rb +99 -0
- data/examples/debug_secrets.rb +84 -0
- data/examples/demo_list_secrets.rb +182 -0
- data/examples/download_files.rb +100 -0
- data/examples/flexible_records_example.rb +94 -0
- data/examples/folder_hierarchy_demo.rb +109 -0
- data/examples/full_demo.rb +176 -0
- data/examples/my_test_standalone.rb +176 -0
- data/examples/simple_test.rb +162 -0
- data/examples/storage_examples.rb +126 -0
- data/lib/keeper_secrets_manager/config_keys.rb +27 -0
- data/lib/keeper_secrets_manager/core.rb +1231 -0
- data/lib/keeper_secrets_manager/crypto.rb +348 -0
- data/lib/keeper_secrets_manager/dto/payload.rb +152 -0
- data/lib/keeper_secrets_manager/dto.rb +221 -0
- data/lib/keeper_secrets_manager/errors.rb +79 -0
- data/lib/keeper_secrets_manager/field_types.rb +152 -0
- data/lib/keeper_secrets_manager/folder_manager.rb +114 -0
- data/lib/keeper_secrets_manager/keeper_globals.rb +59 -0
- data/lib/keeper_secrets_manager/notation.rb +354 -0
- data/lib/keeper_secrets_manager/notation_enhancements.rb +67 -0
- data/lib/keeper_secrets_manager/storage.rb +254 -0
- data/lib/keeper_secrets_manager/totp.rb +140 -0
- data/lib/keeper_secrets_manager/utils.rb +196 -0
- data/lib/keeper_secrets_manager/version.rb +3 -0
- data/lib/keeper_secrets_manager.rb +38 -0
- metadata +82 -0
@@ -0,0 +1,354 @@
|
|
1
|
+
require 'base64'
|
2
|
+
|
3
|
+
module KeeperSecretsManager
|
4
|
+
module Notation
|
5
|
+
# Parse and resolve keeper:// notation URIs
|
6
|
+
class Parser
|
7
|
+
ESCAPE_CHAR = '\\'.freeze
|
8
|
+
ESCAPE_CHARS = '/[]\\'.freeze # Characters that can be escaped
|
9
|
+
|
10
|
+
def initialize(secrets_manager)
|
11
|
+
@secrets_manager = secrets_manager
|
12
|
+
end
|
13
|
+
|
14
|
+
# Parse notation and return value
|
15
|
+
def parse(notation)
|
16
|
+
return nil if notation.nil? || notation.empty?
|
17
|
+
|
18
|
+
# Parse notation URI
|
19
|
+
parsed = parse_notation(notation)
|
20
|
+
|
21
|
+
# Validate we have minimum required sections
|
22
|
+
raise NotationError, "Invalid notation: #{notation}" if parsed.length < 3
|
23
|
+
|
24
|
+
# Extract components
|
25
|
+
record_token = parsed[1].text&.first
|
26
|
+
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
|
+
|
31
|
+
# Get record
|
32
|
+
records = @secrets_manager.get_secrets([record_token])
|
33
|
+
|
34
|
+
# If not found by UID, try by title
|
35
|
+
if records.empty?
|
36
|
+
all_records = @secrets_manager.get_secrets
|
37
|
+
records = all_records.select { |r| r.title == record_token }
|
38
|
+
end
|
39
|
+
|
40
|
+
raise NotationError, "Multiple records match '#{record_token}'" if records.size > 1
|
41
|
+
raise NotationError, "No records match '#{record_token}'" if records.empty?
|
42
|
+
|
43
|
+
record = records.first
|
44
|
+
|
45
|
+
# Extract parameters
|
46
|
+
parameter = parsed[2].parameter&.first
|
47
|
+
index1 = parsed[2].index1&.first
|
48
|
+
index2 = parsed[2].index2&.first
|
49
|
+
|
50
|
+
# Process selector
|
51
|
+
case selector.downcase
|
52
|
+
when 'type'
|
53
|
+
record.type
|
54
|
+
when 'title'
|
55
|
+
record.title
|
56
|
+
when 'notes'
|
57
|
+
record.notes
|
58
|
+
when 'file'
|
59
|
+
handle_file_selector(record, parameter, record_token)
|
60
|
+
when 'field', 'custom_field'
|
61
|
+
handle_field_selector(record, selector, parameter, index1, index2, parsed[2])
|
62
|
+
else
|
63
|
+
raise NotationError, "Invalid selector: #{selector}"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# Handle file selector
|
70
|
+
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
|
+
|
74
|
+
# Find matching file
|
75
|
+
files = record.files.select do |f|
|
76
|
+
parameter == f.name || parameter == f.title || parameter == f.uid
|
77
|
+
end
|
78
|
+
|
79
|
+
raise NotationError, "No files match '#{parameter}'" if files.empty?
|
80
|
+
raise NotationError, "Multiple files match '#{parameter}'" if files.size > 1
|
81
|
+
|
82
|
+
# Return file object (downloading would be handled by the caller)
|
83
|
+
files.first
|
84
|
+
end
|
85
|
+
|
86
|
+
# Handle field selector
|
87
|
+
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
|
+
|
94
|
+
raise NotationError, "Field '#{parameter}' not found" unless field
|
95
|
+
|
96
|
+
# Get field values
|
97
|
+
values = field['value'] || []
|
98
|
+
|
99
|
+
# Handle index1
|
100
|
+
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}"
|
111
|
+
end
|
112
|
+
|
113
|
+
# Apply index1
|
114
|
+
values = [values[idx]] if idx >= 0
|
115
|
+
|
116
|
+
# 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] == '[]' &&
|
122
|
+
(index2.nil? || index2.empty?)
|
123
|
+
return values
|
124
|
+
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
|
+
|
130
|
+
# Handle index2 (property access)
|
131
|
+
full_obj_value = parsed_section.index2.nil? ||
|
132
|
+
parsed_section.index2[1] == '' ||
|
133
|
+
parsed_section.index2[1] == '[]'
|
134
|
+
|
135
|
+
if full_obj_value
|
136
|
+
idx >= 0 ? values.first : values
|
137
|
+
elsif values.first.is_a?(Hash)
|
138
|
+
obj_property = index2
|
139
|
+
if values.first.key?(obj_property)
|
140
|
+
values.first[obj_property]
|
141
|
+
else
|
142
|
+
raise NotationError, "Property '#{obj_property}' not found"
|
143
|
+
end
|
144
|
+
else
|
145
|
+
raise NotationError, "Cannot extract property from non-object value"
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# Parse index value
|
150
|
+
def parse_index(index_str)
|
151
|
+
return -1 if index_str.nil? || index_str.empty?
|
152
|
+
|
153
|
+
begin
|
154
|
+
Integer(index_str)
|
155
|
+
rescue ArgumentError
|
156
|
+
-1
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Parse notation URI into sections
|
161
|
+
def parse_notation(notation)
|
162
|
+
# Handle base64 encoded notation
|
163
|
+
unless notation.include?('/')
|
164
|
+
begin
|
165
|
+
decoded = Base64.urlsafe_decode64(notation)
|
166
|
+
notation = decoded.force_encoding('UTF-8')
|
167
|
+
rescue
|
168
|
+
raise NotationError, "Invalid notation format"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Parse sections
|
173
|
+
prefix = parse_section(notation, 'prefix', 0)
|
174
|
+
pos = prefix.present? ? prefix.end_pos + 1 : 0
|
175
|
+
|
176
|
+
record = parse_section(notation, 'record', pos)
|
177
|
+
pos = record.present? ? record.end_pos + 1 : notation.length
|
178
|
+
|
179
|
+
selector = parse_section(notation, 'selector', pos)
|
180
|
+
pos = selector.present? ? selector.end_pos + 1 : notation.length
|
181
|
+
|
182
|
+
footer = parse_section(notation, 'footer', pos)
|
183
|
+
|
184
|
+
[prefix, record, selector, footer]
|
185
|
+
end
|
186
|
+
|
187
|
+
# Parse a section of the notation
|
188
|
+
def parse_section(notation, section_name, pos)
|
189
|
+
result = NotationSection.new(section_name)
|
190
|
+
result.start_pos = pos
|
191
|
+
|
192
|
+
case section_name.downcase
|
193
|
+
when 'prefix'
|
194
|
+
# Check for keeper:// prefix
|
195
|
+
prefix = "#{Core::SecretsManager::NOTATION_PREFIX}://"
|
196
|
+
if notation.downcase.start_with?(prefix.downcase)
|
197
|
+
result.present = true
|
198
|
+
result.start_pos = 0
|
199
|
+
result.end_pos = prefix.length - 1
|
200
|
+
result.text = [notation[0...prefix.length], notation[0...prefix.length]]
|
201
|
+
end
|
202
|
+
|
203
|
+
when 'footer'
|
204
|
+
# Footer is anything after the last section
|
205
|
+
if pos < notation.length
|
206
|
+
result.present = true
|
207
|
+
result.start_pos = pos
|
208
|
+
result.end_pos = notation.length - 1
|
209
|
+
result.text = [notation[pos..], notation[pos..]]
|
210
|
+
end
|
211
|
+
|
212
|
+
when 'record'
|
213
|
+
# Record is required - parse until '/' with escaping
|
214
|
+
if pos < notation.length
|
215
|
+
parsed = parse_subsection(notation, pos, '/', true)
|
216
|
+
if parsed
|
217
|
+
result.present = true
|
218
|
+
result.start_pos = pos
|
219
|
+
result.end_pos = pos + parsed[1].length - 1
|
220
|
+
result.text = parsed
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
when 'selector'
|
225
|
+
# Selector is required
|
226
|
+
if pos < notation.length
|
227
|
+
parsed = parse_subsection(notation, pos, '/', false)
|
228
|
+
if parsed
|
229
|
+
result.present = true
|
230
|
+
result.start_pos = pos
|
231
|
+
result.end_pos = pos + parsed[1].length - 1
|
232
|
+
result.text = parsed
|
233
|
+
|
234
|
+
# Check for long selectors that have parameters
|
235
|
+
if %w[field custom_field file].include?(parsed[0].downcase)
|
236
|
+
# Parse parameter (field type/label or filename)
|
237
|
+
param_parsed = parse_subsection(notation, result.end_pos + 1, '[', true)
|
238
|
+
if param_parsed
|
239
|
+
result.parameter = param_parsed
|
240
|
+
plen = param_parsed[1].length
|
241
|
+
plen -= 1 if param_parsed[1].end_with?('[') && !param_parsed[1].end_with?('\\[')
|
242
|
+
result.end_pos += plen
|
243
|
+
|
244
|
+
# Parse index1 [N] or []
|
245
|
+
index1_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
|
246
|
+
if index1_parsed
|
247
|
+
result.index1 = index1_parsed
|
248
|
+
result.end_pos += index1_parsed[1].length
|
249
|
+
|
250
|
+
# Parse index2 [property]
|
251
|
+
index2_parsed = parse_subsection(notation, result.end_pos + 1, '[]', true)
|
252
|
+
if index2_parsed
|
253
|
+
result.index2 = index2_parsed
|
254
|
+
result.end_pos += index2_parsed[1].length
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
else
|
263
|
+
raise NotationError, "Unknown section: #{section_name}"
|
264
|
+
end
|
265
|
+
|
266
|
+
result
|
267
|
+
end
|
268
|
+
|
269
|
+
# Parse subsection with delimiters and escaping
|
270
|
+
def parse_subsection(text, pos, delimiters, escaped = false)
|
271
|
+
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
|
+
|
277
|
+
token = ''
|
278
|
+
raw = ''
|
279
|
+
|
280
|
+
while pos < text.length
|
281
|
+
if escaped && text[pos] == ESCAPE_CHAR
|
282
|
+
# Handle escape sequence
|
283
|
+
if pos + 1 >= text.length || !ESCAPE_CHARS.include?(text[pos + 1])
|
284
|
+
raise NotationError, "Incorrect escape sequence at position #{pos}"
|
285
|
+
end
|
286
|
+
|
287
|
+
token += text[pos + 1]
|
288
|
+
raw += text[pos, 2]
|
289
|
+
pos += 2
|
290
|
+
else
|
291
|
+
raw += text[pos]
|
292
|
+
|
293
|
+
if delimiters.length == 1
|
294
|
+
# Single delimiter
|
295
|
+
break if text[pos] == delimiters[0]
|
296
|
+
token += text[pos]
|
297
|
+
else
|
298
|
+
# Two delimiters (for brackets)
|
299
|
+
if raw[0] != delimiters[0]
|
300
|
+
raise NotationError, "Index sections must start with '['"
|
301
|
+
end
|
302
|
+
|
303
|
+
if raw.length > 1 && text[pos] == delimiters[0]
|
304
|
+
raise NotationError, "Index sections do not allow extra '[' inside"
|
305
|
+
end
|
306
|
+
|
307
|
+
if !delimiters.include?(text[pos])
|
308
|
+
token += text[pos]
|
309
|
+
elsif text[pos] == delimiters[1]
|
310
|
+
break
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
pos += 1
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
# Validate brackets are properly closed
|
319
|
+
if delimiters.length == 2
|
320
|
+
if raw.length < 2 || raw[0] != delimiters[0] || raw[-1] != delimiters[1]
|
321
|
+
raise NotationError, "Index sections must be enclosed in '[' and ']'"
|
322
|
+
end
|
323
|
+
|
324
|
+
if escaped && raw[-2] == ESCAPE_CHAR
|
325
|
+
raise NotationError, "Index sections must be enclosed in '[' and ']'"
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
[token, raw]
|
330
|
+
end
|
331
|
+
|
332
|
+
# Notation section data class
|
333
|
+
class NotationSection
|
334
|
+
attr_accessor :section, :present, :start_pos, :end_pos,
|
335
|
+
:text, :parameter, :index1, :index2
|
336
|
+
|
337
|
+
def initialize(section_name)
|
338
|
+
@section = section_name
|
339
|
+
@present = false
|
340
|
+
@start_pos = -1
|
341
|
+
@end_pos = -1
|
342
|
+
@text = nil
|
343
|
+
@parameter = nil
|
344
|
+
@index1 = nil
|
345
|
+
@index2 = nil
|
346
|
+
end
|
347
|
+
|
348
|
+
def present?
|
349
|
+
@present
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# Enhanced notation functionality for files and TOTP
|
2
|
+
|
3
|
+
module KeeperSecretsManager
|
4
|
+
module Notation
|
5
|
+
class Parser
|
6
|
+
# Get value with enhanced functionality
|
7
|
+
# This method extends the basic parse method to handle special cases
|
8
|
+
def get_value(notation, options = {})
|
9
|
+
value = parse(notation)
|
10
|
+
|
11
|
+
# Check if we should process special types
|
12
|
+
return value unless options[:auto_process]
|
13
|
+
|
14
|
+
# Parse the notation to understand what we're dealing with
|
15
|
+
parsed = parse_notation(notation)
|
16
|
+
return value if parsed.length < 3
|
17
|
+
|
18
|
+
selector = parsed[2].text&.first
|
19
|
+
return value unless selector
|
20
|
+
|
21
|
+
case selector.downcase
|
22
|
+
when 'file'
|
23
|
+
# If it's a file and auto_download is enabled, download it
|
24
|
+
if options[:auto_download] && value.is_a?(Hash) && value['fileUid']
|
25
|
+
begin
|
26
|
+
file_data = @secrets_manager.download_file(value['fileUid'])
|
27
|
+
return file_data['data'] # Return file content
|
28
|
+
rescue => e
|
29
|
+
raise NotationError, "Failed to download file: #{e.message}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
when 'field'
|
34
|
+
# Check if it's a TOTP field
|
35
|
+
parameter = parsed[2].parameter&.first
|
36
|
+
if parameter && parameter.downcase == 'onetimecode' && value.is_a?(String) && value.start_with?('otpauth://')
|
37
|
+
if options[:generate_totp_code]
|
38
|
+
begin
|
39
|
+
totp_params = TOTP.parse_url(value)
|
40
|
+
return TOTP.generate_code(
|
41
|
+
totp_params['secret'],
|
42
|
+
algorithm: totp_params['algorithm'],
|
43
|
+
digits: totp_params['digits'],
|
44
|
+
period: totp_params['period']
|
45
|
+
)
|
46
|
+
rescue => e
|
47
|
+
raise NotationError, "Failed to generate TOTP code: #{e.message}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
value
|
54
|
+
end
|
55
|
+
|
56
|
+
# Convenience method to get TOTP code directly
|
57
|
+
def get_totp_code(notation)
|
58
|
+
get_value(notation, auto_process: true, generate_totp_code: true)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convenience method to download file content directly
|
62
|
+
def download_file(notation)
|
63
|
+
get_value(notation, auto_process: true, auto_download: true)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,254 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
module KeeperSecretsManager
|
6
|
+
module Storage
|
7
|
+
# Base storage interface
|
8
|
+
module KeyValueStorage
|
9
|
+
def get_string(key)
|
10
|
+
raise NotImplementedError, "Subclass must implement get_string"
|
11
|
+
end
|
12
|
+
|
13
|
+
def save_string(key, value)
|
14
|
+
raise NotImplementedError, "Subclass must implement save_string"
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_bytes(key)
|
18
|
+
data = get_string(key)
|
19
|
+
return nil unless data
|
20
|
+
|
21
|
+
# Handle both standard and URL-safe base64
|
22
|
+
begin
|
23
|
+
# First try standard base64
|
24
|
+
Base64.strict_decode64(data)
|
25
|
+
rescue ArgumentError
|
26
|
+
begin
|
27
|
+
# Try URL-safe base64 with padding
|
28
|
+
padding = 4 - (data.length % 4)
|
29
|
+
padding = 0 if padding == 4
|
30
|
+
Base64.urlsafe_decode64(data + '=' * padding)
|
31
|
+
rescue => e
|
32
|
+
# Last resort - try with decode64 which is more lenient
|
33
|
+
Base64.decode64(data)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def save_bytes(key, value)
|
39
|
+
save_string(key, Base64.strict_encode64(value))
|
40
|
+
end
|
41
|
+
|
42
|
+
def delete(key)
|
43
|
+
raise NotImplementedError, "Subclass must implement delete"
|
44
|
+
end
|
45
|
+
|
46
|
+
def contains?(key)
|
47
|
+
!get_string(key).nil?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# In-memory storage implementation
|
52
|
+
class InMemoryStorage
|
53
|
+
include KeyValueStorage
|
54
|
+
|
55
|
+
def initialize(config_data = nil)
|
56
|
+
@data = {}
|
57
|
+
|
58
|
+
# Initialize from JSON string, base64 string, or hash
|
59
|
+
if config_data
|
60
|
+
parsed = case config_data
|
61
|
+
when String
|
62
|
+
# Check if it's base64 encoded
|
63
|
+
if is_base64?(config_data)
|
64
|
+
JSON.parse(Base64.decode64(config_data))
|
65
|
+
else
|
66
|
+
JSON.parse(config_data)
|
67
|
+
end
|
68
|
+
when Hash
|
69
|
+
config_data
|
70
|
+
else
|
71
|
+
{}
|
72
|
+
end
|
73
|
+
|
74
|
+
parsed.each { |k, v| @data[k.to_s] = v.to_s }
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def get_string(key)
|
79
|
+
@data[key.to_s]
|
80
|
+
end
|
81
|
+
|
82
|
+
def save_string(key, value)
|
83
|
+
@data[key.to_s] = value.to_s
|
84
|
+
end
|
85
|
+
|
86
|
+
def delete(key)
|
87
|
+
@data.delete(key.to_s)
|
88
|
+
end
|
89
|
+
|
90
|
+
def to_h
|
91
|
+
@data.dup
|
92
|
+
end
|
93
|
+
|
94
|
+
def to_json(*args)
|
95
|
+
@data.to_json(*args)
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def is_base64?(str)
|
101
|
+
# Check if string is valid base64
|
102
|
+
return false if str.nil? || str.empty?
|
103
|
+
|
104
|
+
# Remove whitespace
|
105
|
+
str = str.strip
|
106
|
+
|
107
|
+
# Check if length is multiple of 4 (with padding) or can be padded to multiple of 4
|
108
|
+
# Also check if it only contains base64 characters
|
109
|
+
base64_regex = /\A[A-Za-z0-9+\/]*={0,2}\z/
|
110
|
+
|
111
|
+
str.match?(base64_regex) && (str.length % 4 == 0 || str.length % 4 == 2 || str.length % 4 == 3)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
# File-based storage implementation
|
116
|
+
class FileStorage
|
117
|
+
include KeyValueStorage
|
118
|
+
|
119
|
+
def initialize(filename = 'keeper_config.json')
|
120
|
+
@filename = File.expand_path(filename)
|
121
|
+
@data = {}
|
122
|
+
load_data
|
123
|
+
end
|
124
|
+
|
125
|
+
def get_string(key)
|
126
|
+
@data[key.to_s]
|
127
|
+
end
|
128
|
+
|
129
|
+
def save_string(key, value)
|
130
|
+
@data[key.to_s] = value.to_s
|
131
|
+
save_data
|
132
|
+
end
|
133
|
+
|
134
|
+
def delete(key)
|
135
|
+
@data.delete(key.to_s)
|
136
|
+
save_data
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def load_data
|
142
|
+
if File.exist?(@filename)
|
143
|
+
begin
|
144
|
+
content = File.read(@filename)
|
145
|
+
# Handle empty files
|
146
|
+
if content.strip.empty?
|
147
|
+
@data = {}
|
148
|
+
else
|
149
|
+
@data = JSON.parse(content)
|
150
|
+
end
|
151
|
+
rescue JSON::ParserError => e
|
152
|
+
raise Error, "Failed to parse config file: #{e.message}"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def save_data
|
158
|
+
# Ensure directory exists
|
159
|
+
FileUtils.mkdir_p(File.dirname(@filename))
|
160
|
+
|
161
|
+
# Write atomically to avoid corruption
|
162
|
+
temp_file = "#{@filename}.tmp"
|
163
|
+
File.open(temp_file, 'w') do |f|
|
164
|
+
f.write(JSON.pretty_generate(@data))
|
165
|
+
end
|
166
|
+
|
167
|
+
# Move atomically
|
168
|
+
File.rename(temp_file, @filename)
|
169
|
+
|
170
|
+
# Set restrictive permissions (owner read/write only)
|
171
|
+
File.chmod(0600, @filename)
|
172
|
+
rescue => e
|
173
|
+
raise Error, "Failed to save config file: #{e.message}"
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# Environment-based storage (read-only)
|
178
|
+
class EnvironmentStorage
|
179
|
+
include KeyValueStorage
|
180
|
+
|
181
|
+
def initialize(prefix = 'KSM_')
|
182
|
+
@prefix = prefix
|
183
|
+
end
|
184
|
+
|
185
|
+
def get_string(key)
|
186
|
+
ENV["#{@prefix}#{key.to_s.upcase}"]
|
187
|
+
end
|
188
|
+
|
189
|
+
def save_string(key, value)
|
190
|
+
raise Error, "Environment storage is read-only"
|
191
|
+
end
|
192
|
+
|
193
|
+
def delete(key)
|
194
|
+
raise Error, "Environment storage is read-only"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Cacheable storage wrapper
|
199
|
+
class CachingStorage
|
200
|
+
include KeyValueStorage
|
201
|
+
|
202
|
+
def initialize(base_storage, ttl_seconds = 600)
|
203
|
+
@base_storage = base_storage
|
204
|
+
@ttl_seconds = ttl_seconds
|
205
|
+
@cache = {}
|
206
|
+
@timestamps = {}
|
207
|
+
end
|
208
|
+
|
209
|
+
def get_string(key)
|
210
|
+
key_str = key.to_s
|
211
|
+
|
212
|
+
# Check cache validity
|
213
|
+
if @cache.key?(key_str) && !expired?(key_str)
|
214
|
+
return @cache[key_str]
|
215
|
+
end
|
216
|
+
|
217
|
+
# Fetch from base storage
|
218
|
+
value = @base_storage.get_string(key)
|
219
|
+
if value
|
220
|
+
@cache[key_str] = value
|
221
|
+
@timestamps[key_str] = Time.now
|
222
|
+
end
|
223
|
+
|
224
|
+
value
|
225
|
+
end
|
226
|
+
|
227
|
+
def save_string(key, value)
|
228
|
+
key_str = key.to_s
|
229
|
+
@base_storage.save_string(key, value)
|
230
|
+
@cache[key_str] = value.to_s
|
231
|
+
@timestamps[key_str] = Time.now
|
232
|
+
end
|
233
|
+
|
234
|
+
def delete(key)
|
235
|
+
key_str = key.to_s
|
236
|
+
@base_storage.delete(key)
|
237
|
+
@cache.delete(key_str)
|
238
|
+
@timestamps.delete(key_str)
|
239
|
+
end
|
240
|
+
|
241
|
+
def clear_cache
|
242
|
+
@cache.clear
|
243
|
+
@timestamps.clear
|
244
|
+
end
|
245
|
+
|
246
|
+
private
|
247
|
+
|
248
|
+
def expired?(key)
|
249
|
+
return true unless @timestamps[key]
|
250
|
+
Time.now - @timestamps[key] > @ttl_seconds
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|