keysloth 0.1.1

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,450 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ module KeySloth
7
+ # Класс для управления файловыми операциями KeySloth
8
+ #
9
+ # Обеспечивает создание директорий, чтение и запись файлов,
10
+ # создание резервных копий и валидацию путей.
11
+ # Поддерживает работу с различными типами секретных файлов.
12
+ #
13
+ # @example Использование
14
+ # file_manager = KeySloth::FileManager.new
15
+ #
16
+ # # Создание директории
17
+ # file_manager.ensure_directory('./secrets')
18
+ #
19
+ # # Сбор файлов секретов
20
+ # files = file_manager.collect_secret_files('./secrets')
21
+ #
22
+ # # Создание backup'а
23
+ # file_manager.create_backup('./secrets')
24
+ #
25
+ # @author KeySloth Team
26
+ # @since 0.1.0
27
+ class FileManager
28
+ # Поддерживаемые расширения файлов секретов
29
+ SECRET_FILE_EXTENSIONS = %w[.cer .p12 .mobileprovisioning .json].freeze
30
+
31
+ # Максимальное количество backup'ов
32
+ DEFAULT_BACKUP_COUNT = 3
33
+
34
+ # Инициализация файлового менеджера
35
+ #
36
+ # @param logger [KeySloth::Logger] Логгер для вывода сообщений
37
+ # @param backup_count [Integer] Количество backup'ов для хранения
38
+ def initialize(logger = nil, backup_count = DEFAULT_BACKUP_COUNT)
39
+ @logger = logger || Logger.new(level: :error)
40
+ @backup_count = backup_count
41
+ end
42
+
43
+ # Обеспечивает существование директории
44
+ #
45
+ # @param path [String] Путь к директории
46
+ # @raise [FileSystemError] при ошибках создания директории
47
+ def ensure_directory(path)
48
+ return if directory_exists?(path)
49
+
50
+ @logger.info("Создаем директорию: #{path}")
51
+
52
+ begin
53
+ FileUtils.mkdir_p(path)
54
+ @logger.debug("Директория успешно создана: #{path}")
55
+ rescue StandardError => e
56
+ @logger.error('Ошибка создания директории', e)
57
+ raise FileSystemError, "Не удалось создать директорию #{path}: #{e.message}"
58
+ end
59
+ end
60
+
61
+ # Проверяет существование директории
62
+ #
63
+ # @param path [String] Путь к директории
64
+ # @return [Boolean] true если директория существует
65
+ def directory_exists?(path)
66
+ File.directory?(path)
67
+ end
68
+
69
+ # Читает содержимое файла
70
+ #
71
+ # @param file_path [String] Путь к файлу
72
+ # @return [String] Содержимое файла
73
+ # @raise [FileSystemError] при ошибках чтения файла
74
+ def read_file(file_path)
75
+ @logger.debug("Читаем файл: #{file_path}")
76
+
77
+ begin
78
+ content = File.read(file_path)
79
+ @logger.debug("Файл прочитан успешно (размер: #{content.length} байт)")
80
+ content
81
+ rescue StandardError => e
82
+ @logger.error('Ошибка чтения файла', e)
83
+ raise FileSystemError, "Не удалось прочитать файл #{file_path}: #{e.message}"
84
+ end
85
+ end
86
+
87
+ # Записывает содержимое в файл
88
+ #
89
+ # @param file_path [String] Путь к файлу
90
+ # @param content [String] Содержимое для записи
91
+ # @raise [FileSystemError] при ошибках записи файла
92
+ def write_file(file_path, content)
93
+ @logger.debug("Записываем файл: #{file_path}")
94
+
95
+ begin
96
+ # Создаем директорию если не существует
97
+ directory = File.dirname(file_path)
98
+ ensure_directory(directory) unless directory_exists?(directory)
99
+
100
+ File.write(file_path, content)
101
+ @logger.debug("Файл записан успешно (размер: #{content.length} байт)")
102
+ rescue StandardError => e
103
+ @logger.error('Ошибка записи файла', e)
104
+ raise FileSystemError, "Не удалось записать файл #{file_path}: #{e.message}"
105
+ end
106
+ end
107
+
108
+ # Собирает все файлы секретов из директории
109
+ #
110
+ # @param directory_path [String] Путь к директории с секретами
111
+ # @return [Array<String>] Массив путей к файлам секретов
112
+ # @raise [FileSystemError] при ошибках доступа к директории
113
+ def collect_secret_files(directory_path)
114
+ @logger.debug("Собираем файлы секретов из: #{directory_path}")
115
+
116
+ begin
117
+ validate_directory_access!(directory_path)
118
+
119
+ files = []
120
+ SECRET_FILE_EXTENSIONS.each do |extension|
121
+ pattern = File.join(directory_path, "**/*#{extension}")
122
+ matching_files = Dir.glob(pattern)
123
+ files.concat(matching_files)
124
+ end
125
+
126
+ @logger.info("Найдено #{files.size} файлов секретов")
127
+ files.sort
128
+ rescue StandardError => e
129
+ @logger.error('Ошибка сбора файлов секретов', e)
130
+ raise FileSystemError, "Не удалось собрать файлы секретов: #{e.message}"
131
+ end
132
+ end
133
+
134
+ # Возвращает относительный путь файла
135
+ #
136
+ # @param file_path [String] Полный путь к файлу
137
+ # @param base_path [String] Базовый путь
138
+ # @return [String] Относительный путь
139
+ def get_relative_path(file_path, base_path)
140
+ Pathname.new(file_path).relative_path_from(Pathname.new(base_path)).to_s
141
+ end
142
+
143
+ # Создает резервную копию директории
144
+ #
145
+ # @param directory_path [String] Путь к директории для backup'а
146
+ # @return [String, nil] Путь к созданному backup'у или nil если директория не существует
147
+ # @raise [FileSystemError] при ошибках создания backup'а
148
+ def create_backup(directory_path)
149
+ return nil unless directory_exists?(directory_path)
150
+
151
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
152
+ backup_name = "#{File.basename(directory_path)}_backup_#{timestamp}"
153
+ backup_path = File.join(File.dirname(directory_path), backup_name)
154
+
155
+ @logger.info("Создаем backup: #{backup_path}")
156
+
157
+ begin
158
+ FileUtils.cp_r(directory_path, backup_path)
159
+
160
+ # Очищаем старые backup'ы
161
+ cleanup_old_backups(File.dirname(directory_path), File.basename(directory_path))
162
+
163
+ @logger.debug("Backup успешно создан: #{backup_path}")
164
+ backup_path
165
+ rescue StandardError => e
166
+ @logger.error('Ошибка создания backup', e)
167
+ raise FileSystemError, "Не удалось создать backup #{directory_path}: #{e.message}"
168
+ end
169
+ end
170
+
171
+ # Восстанавливает из backup'а
172
+ #
173
+ # @param backup_path [String] Путь к backup'у
174
+ # @param target_path [String] Путь для восстановления
175
+ # @raise [FileSystemError] при ошибках восстановления
176
+ def restore_from_backup(backup_path, target_path)
177
+ @logger.info("Восстанавливаем из backup: #{backup_path}")
178
+
179
+ begin
180
+ validate_backup_path!(backup_path)
181
+
182
+ # Удаляем существующую директорию
183
+ FileUtils.rm_rf(target_path)
184
+
185
+ # Копируем из backup'а
186
+ FileUtils.cp_r(backup_path, target_path)
187
+
188
+ @logger.info('Восстановление из backup завершено')
189
+ rescue StandardError => e
190
+ @logger.error('Ошибка восстановления из backup', e)
191
+ raise FileSystemError, "Не удалось восстановить из backup: #{e.message}"
192
+ end
193
+ end
194
+
195
+ # Возвращает список доступных backup'ов
196
+ #
197
+ # @param directory_path [String] Путь к директории
198
+ # @return [Array<String>] Массив путей к backup'ам (отсортированный по времени)
199
+ def list_backups(directory_path)
200
+ base_name = File.basename(directory_path)
201
+ parent_dir = File.dirname(directory_path)
202
+ backup_pattern = File.join(parent_dir, "#{base_name}_backup_*")
203
+
204
+ Dir.glob(backup_pattern)
205
+ .select { |path| File.directory?(path) }
206
+ .sort
207
+ .reverse # Новые backup'ы первыми
208
+ end
209
+
210
+ # Проверяет целостность файла секрета
211
+ #
212
+ # @param file_path [String] Путь к файлу
213
+ # @return [Boolean] true если файл прошел проверку целостности
214
+ def verify_file_integrity(file_path)
215
+ return false unless File.exist?(file_path)
216
+ return false if File.empty?(file_path)
217
+
218
+ # Базовая проверка на читаемость
219
+ begin
220
+ content = File.read(file_path, 100) # Читаем первые 100 байт для проверки
221
+
222
+ # Дополнительная проверка по типу файла
223
+ file_extension = File.extname(file_path).downcase
224
+ verify_file_type_integrity(content, file_extension)
225
+ rescue StandardError => e
226
+ @logger.debug("Ошибка проверки целостности файла #{file_path}: #{e.message}")
227
+ false
228
+ end
229
+ end
230
+
231
+ # Выполняет детальную проверку целостности файла
232
+ #
233
+ # @param file_path [String] Путь к файлу
234
+ # @return [Hash] Детальный результат проверки
235
+ def verify_file_integrity_detailed(file_path)
236
+ result = {
237
+ valid: false,
238
+ exists: false,
239
+ readable: false,
240
+ non_empty: false,
241
+ type_valid: false,
242
+ size: 0,
243
+ error: nil
244
+ }
245
+
246
+ begin
247
+ # Проверяем существование
248
+ result[:exists] = File.exist?(file_path)
249
+ return result unless result[:exists]
250
+
251
+ # Проверяем размер
252
+ result[:size] = File.size(file_path)
253
+ result[:non_empty] = result[:size].positive?
254
+ return result unless result[:non_empty]
255
+
256
+ # Проверяем читаемость
257
+ content = File.read(file_path, 200) # Читаем больше для детальной проверки
258
+ result[:readable] = true
259
+
260
+ # Проверяем соответствие типу файла
261
+ file_extension = File.extname(file_path).downcase
262
+ result[:type_valid] = verify_file_type_integrity(content, file_extension)
263
+
264
+ result[:valid] = result[:exists] && result[:readable] &&
265
+ result[:non_empty] && result[:type_valid]
266
+
267
+ @logger.debug("Детальная проверка файла #{file_path}: #{result}")
268
+ rescue StandardError => e
269
+ result[:error] = e.message
270
+ @logger.debug("Ошибка детальной проверки файла #{file_path}: #{e.message}")
271
+ end
272
+
273
+ result
274
+ end
275
+
276
+ # Валидирует путь файла
277
+ #
278
+ # @param file_path [String] Путь к файлу
279
+ # @raise [ValidationError] при некорректном пути
280
+ def validate_file_path!(file_path)
281
+ if file_path.nil? || file_path.empty?
282
+ raise ValidationError, 'Путь к файлу не может быть пустым'
283
+ end
284
+
285
+ # Проверяем на потенциально опасные пути
286
+ normalized_path = File.expand_path(file_path)
287
+ if normalized_path.include?('..')
288
+ raise ValidationError, 'Путь содержит потенциально опасные элементы'
289
+ end
290
+ end
291
+
292
+ private
293
+
294
+ # Валидирует доступ к директории
295
+ #
296
+ # @param directory_path [String] Путь к директории
297
+ # @raise [FileSystemError] при проблемах доступа
298
+ def validate_directory_access!(directory_path)
299
+ unless directory_exists?(directory_path)
300
+ raise FileSystemError, "Директория не существует: #{directory_path}"
301
+ end
302
+
303
+ unless File.readable?(directory_path)
304
+ raise FileSystemError, "Директория не доступна для чтения: #{directory_path}"
305
+ end
306
+ end
307
+
308
+ # Валидирует путь к backup'у
309
+ #
310
+ # @param backup_path [String] Путь к backup'у
311
+ # @raise [FileSystemError] при некорректном backup'е
312
+ def validate_backup_path!(backup_path)
313
+ raise FileSystemError, "Backup не существует: #{backup_path}" unless File.exist?(backup_path)
314
+
315
+ unless File.directory?(backup_path)
316
+ raise FileSystemError, "Backup не является директорией: #{backup_path}"
317
+ end
318
+ end
319
+
320
+ # Очищает старые backup'ы
321
+ #
322
+ # @param parent_dir [String] Родительская директория
323
+ # @param base_name [String] Базовое имя директории
324
+ def cleanup_old_backups(parent_dir, base_name)
325
+ backups = list_backups(File.join(parent_dir, base_name))
326
+
327
+ return if backups.size <= @backup_count
328
+
329
+ # Удаляем старые backup'ы, оставляя только нужное количество
330
+ backups.drop(@backup_count).each do |old_backup|
331
+ @logger.debug("Удаляем старый backup: #{old_backup}")
332
+ FileUtils.remove_entry(old_backup)
333
+ end
334
+ end
335
+
336
+ # Проверяет целостность файла по его типу
337
+ #
338
+ # @param content [String] Содержимое файла (первые байты)
339
+ # @param file_extension [String] Расширение файла
340
+ # @return [Boolean] true если файл соответствует ожидаемому формату
341
+ def verify_file_type_integrity(content, file_extension)
342
+ return false if content.nil? || content.empty?
343
+
344
+ case file_extension
345
+ when '.cer'
346
+ # Проверяем сертификат (.cer файлы)
347
+ verify_certificate_format(content)
348
+ when '.p12'
349
+ # Проверяем PKCS#12 файлы
350
+ verify_p12_format(content)
351
+ when '.mobileprovisioning'
352
+ # Проверяем файлы mobile provisioning
353
+ verify_mobileprovisioning_format(content)
354
+ when '.json'
355
+ # Проверяем JSON файлы
356
+ verify_json_format(content)
357
+ else
358
+ # Для неизвестных типов - только базовая проверка
359
+ @logger.debug("Неизвестный тип файла: #{file_extension}")
360
+ true
361
+ end
362
+ end
363
+
364
+ # Проверяет формат сертификата (.cer файлы)
365
+ #
366
+ # Поддерживает проверку PEM и DER форматов сертификатов.
367
+ # PEM формат содержит текстовые маркеры BEGIN/END CERTIFICATE.
368
+ # DER формат - бинарный ASN.1, начинается с SEQUENCE tag (0x30).
369
+ #
370
+ # @param content [String] Содержимое файла сертификата
371
+ # @return [Boolean] true если формат корректен
372
+ # @example Проверка PEM сертификата
373
+ # content = "-----BEGIN CERTIFICATE-----\nMIIC..."
374
+ # verify_certificate_format(content) #=> true
375
+ def verify_certificate_format(content)
376
+ # PEM формат
377
+ return true if content.include?('-----BEGIN CERTIFICATE-----')
378
+
379
+ # DER формат (бинарный) - проверяем первые байты
380
+ # DER сертификаты начинаются с 0x30 (SEQUENCE tag)
381
+ return true if content.bytes.first == 0x30
382
+
383
+ @logger.debug('Файл не соответствует формату сертификата')
384
+ false
385
+ end
386
+
387
+ # Проверяет формат PKCS#12 (.p12 файлы)
388
+ #
389
+ # PKCS#12 - стандарт для хранения криптографических объектов
390
+ # (сертификаты + приватные ключи). Использует ASN.1 DER кодирование,
391
+ # поэтому файл должен начинаться с SEQUENCE tag (0x30).
392
+ #
393
+ # @param content [String] Содержимое файла PKCS#12
394
+ # @return [Boolean] true если формат корректен
395
+ # @raise [FileSystemError] при невозможности прочитать файл
396
+ def verify_p12_format(content)
397
+ # PKCS#12 файлы начинаются с 0x30 (SEQUENCE tag)
398
+ return true if content.bytes.first == 0x30
399
+
400
+ @logger.debug('Файл не соответствует формату PKCS#12')
401
+ false
402
+ end
403
+
404
+ # Проверяет формат Mobile Provisioning Profile (.mobileprovisioning файлы)
405
+ #
406
+ # Профили подготовки iOS могут быть в XML (текстовом) или бинарном plist формате.
407
+ # XML профили содержат стандартные XML/plist маркеры.
408
+ # Бинарные профили начинаются с 'bplist' сигнатуры.
409
+ #
410
+ # @param content [String] Содержимое файла профиля
411
+ # @return [Boolean] true если формат корректен
412
+ # @example Проверка XML профиля
413
+ # content = "<?xml version=\"1.0\"..."
414
+ # verify_mobileprovisioning_format(content) #=> true
415
+ def verify_mobileprovisioning_format(content)
416
+ # Mobile provisioning файлы содержат XML или plist структуру
417
+ return true if content.include?('<?xml') || content.include?('<plist')
418
+
419
+ # Могут быть в бинарном plist формате
420
+ return true if content.include?('bplist')
421
+
422
+ @logger.debug('Файл не соответствует формату mobile provisioning')
423
+ false
424
+ end
425
+
426
+ # Проверяет формат JSON (.json файлы)
427
+ #
428
+ # Выполняет базовую структурную проверку JSON документов.
429
+ # Проверяет наличие открывающих и закрывающих скобок для объектов {}
430
+ # и массивов []. Не выполняет полную JSON валидацию для производительности.
431
+ #
432
+ # @param content [String] Содержимое JSON файла
433
+ # @return [Boolean] true если базовая структура корректна
434
+ # @example Проверка JSON объекта
435
+ # content = '{"key": "value"}'
436
+ # verify_json_format(content) #=> true
437
+ # @example Проверка JSON массива
438
+ # content = '[{"item": 1}]'
439
+ # verify_json_format(content) #=> true
440
+ def verify_json_format(content)
441
+ # Базовая проверка JSON структуры
442
+ trimmed = content.strip
443
+ return true if (trimmed.start_with?('{') && trimmed.include?('}')) ||
444
+ (trimmed.start_with?('[') && trimmed.include?(']'))
445
+
446
+ @logger.debug('Файл не соответствует формату JSON')
447
+ false
448
+ end
449
+ end
450
+ end