ruborg 0.8.0 → 0.9.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.
data/lib/ruborg/backup.rb CHANGED
@@ -3,12 +3,13 @@
3
3
  module Ruborg
4
4
  # Backup operations using Borg
5
5
  class Backup
6
- def initialize(repository, config:, retention_mode: "standard", repo_name: nil, logger: nil)
6
+ def initialize(repository, config:, retention_mode: "standard", repo_name: nil, logger: nil, skip_hash_check: false)
7
7
  @repository = repository
8
8
  @config = config
9
9
  @retention_mode = retention_mode
10
10
  @repo_name = repo_name
11
11
  @logger = logger
12
+ @skip_hash_check = skip_hash_check
12
13
  end
13
14
 
14
15
  def create(name: nil, remove_source: false)
@@ -62,7 +63,10 @@ module Ruborg
62
63
  skipped_count = 0
63
64
 
64
65
  # rubocop:disable Metrics/BlockLength
65
- files_to_backup.each_with_index do |file_path, index|
66
+ files_to_backup.each_with_index do |file_info, index|
67
+ file_path = file_info[:path]
68
+ source_dir = file_info[:source_dir]
69
+
66
70
  # Generate hash-based archive name with filename
67
71
  path_hash = generate_path_hash(file_path)
68
72
  filename = File.basename(file_path)
@@ -86,15 +90,13 @@ module Ruborg
86
90
  stored_size = stored_info[:size]
87
91
 
88
92
  if current_size == stored_size
89
- # Size same -> verify content hasn't changed (paranoid mode)
90
- current_hash = calculate_file_hash(file_path)
91
- stored_hash = stored_info[:hash]
92
-
93
- if current_hash == stored_hash
94
- # Content truly unchanged - file is already safely backed up
95
- puts " - Archive already exists (file unchanged)"
93
+ # Size same -> verify content hasn't changed (paranoid mode) unless skip_hash_check is enabled
94
+ if @skip_hash_check
95
+ # Skip hash check - assume file is unchanged based on size and mtime
96
+ puts " - Archive already exists (skipped hash check)"
96
97
  @logger&.info(
97
- "[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists (file unchanged)"
98
+ "[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists " \
99
+ "(hash check skipped)"
98
100
  )
99
101
  skipped_count += 1
100
102
 
@@ -103,12 +105,29 @@ module Ruborg
103
105
 
104
106
  next
105
107
  else
106
- # Size same but content changed (rare: edited + truncated/padded to same size)
107
- archive_name = find_next_version_name(archive_name, existing_archives)
108
- @logger&.warn(
109
- "[#{@repo_name}] File content changed but size/mtime unchanged for #{file_path}, " \
110
- "using #{archive_name}"
111
- )
108
+ current_hash = calculate_file_hash(file_path)
109
+ stored_hash = stored_info[:hash]
110
+
111
+ if current_hash == stored_hash
112
+ # Content truly unchanged - file is already safely backed up
113
+ puts " - Archive already exists (file unchanged)"
114
+ @logger&.info(
115
+ "[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists (file unchanged)"
116
+ )
117
+ skipped_count += 1
118
+
119
+ # If remove_source is enabled, delete the file (it's already safely backed up)
120
+ remove_single_file(file_path) if remove_source
121
+
122
+ next
123
+ else
124
+ # Size same but content changed (rare: edited + truncated/padded to same size)
125
+ archive_name = find_next_version_name(archive_name, existing_archives)
126
+ @logger&.warn(
127
+ "[#{@repo_name}] File content changed but size/mtime unchanged for #{file_path}, " \
128
+ "using #{archive_name}"
129
+ )
130
+ end
112
131
  end
113
132
  else
114
133
  # Size changed but mtime same -> content changed, add version suffix
@@ -126,8 +145,8 @@ module Ruborg
126
145
  end
127
146
  end
128
147
 
129
- # Create archive for single file with original path as comment
130
- cmd = build_per_file_create_command(archive_name, file_path)
148
+ # Create archive for single file with source directory in metadata
149
+ cmd = build_per_file_create_command(archive_name, file_path, source_dir)
131
150
 
132
151
  execute_borg_command(cmd)
133
152
  puts ""
@@ -157,13 +176,13 @@ module Ruborg
157
176
  base_path = File.expand_path(base_path)
158
177
 
159
178
  if File.file?(base_path)
160
- files << base_path unless excluded?(base_path, exclude_patterns)
179
+ files << { path: base_path, source_dir: base_path } unless excluded?(base_path, exclude_patterns)
161
180
  elsif File.directory?(base_path)
162
181
  Find.find(base_path) do |path|
163
182
  next unless File.file?(path)
164
183
  next if excluded?(path, exclude_patterns)
165
184
 
166
- files << path
185
+ files << { path: path, source_dir: base_path }
167
186
  end
168
187
  end
169
188
  end
@@ -248,15 +267,15 @@ module Ruborg
248
267
  Digest::SHA256.file(file_path).hexdigest
249
268
  end
250
269
 
251
- def build_per_file_create_command(archive_name, file_path)
270
+ def build_per_file_create_command(archive_name, file_path, source_dir)
252
271
  cmd = [@repository.borg_path, "create"]
253
272
  cmd += ["--compression", @config.compression]
254
273
 
255
- # Store file metadata (path + size + hash) in archive comment for duplicate detection
256
- # Format: path|||size|||hash (using ||| as delimiter to avoid conflicts with paths)
274
+ # Store file metadata (path + size + hash + source_dir) in archive comment
275
+ # Format: path|||size|||hash|||source_dir (using ||| as delimiter to avoid conflicts with paths)
257
276
  file_size = File.size(file_path)
258
277
  file_hash = calculate_file_hash(file_path)
259
- metadata = "#{file_path}|||#{file_size}|||#{file_hash}"
278
+ metadata = "#{file_path}|||#{file_size}|||#{file_hash}|||#{source_dir}"
260
279
  cmd += ["--comment", metadata]
261
280
 
262
281
  cmd << "#{@repository.path}::#{archive_name}"
@@ -470,7 +489,7 @@ module Ruborg
470
489
 
471
490
  unless info_status.success?
472
491
  # If we can't get info for this archive, skip it with defaults
473
- hash[archive_name] = { path: "", size: 0, hash: "" }
492
+ hash[archive_name] = { path: "", size: 0, hash: "", source_dir: "" }
474
493
  next
475
494
  end
476
495
 
@@ -479,34 +498,44 @@ module Ruborg
479
498
  comment = archive_info["comment"] || ""
480
499
 
481
500
  # Parse comment based on format
482
- # The comment field stores metadata as: path|||size|||hash (using ||| as delimiter)
501
+ # The comment field stores metadata as: path|||size|||hash|||source_dir (using ||| as delimiter)
483
502
  # For backward compatibility, handle old formats:
484
503
  # - Old format 1: plain path (no |||)
485
504
  # - Old format 2: path|||hash (2 parts)
486
- # - New format: path|||size|||hash (3 parts)
505
+ # - Old format 3: path|||size|||hash (3 parts)
506
+ # - New format: path|||size|||hash|||source_dir (4 parts)
487
507
  if comment.include?("|||")
488
508
  parts = comment.split("|||")
489
509
  file_path = parts[0]
490
- if parts.length >= 3
491
- # New format: path|||size|||hash
510
+ if parts.length >= 4
511
+ # New format: path|||size|||hash|||source_dir
512
+ file_size = parts[1].to_i
513
+ file_hash = parts[2] || ""
514
+ source_dir = parts[3] || ""
515
+ elsif parts.length >= 3
516
+ # Format 3: path|||size|||hash (no source_dir)
492
517
  file_size = parts[1].to_i
493
518
  file_hash = parts[2] || ""
519
+ source_dir = ""
494
520
  else
495
- # Old format: path|||hash (size not available)
521
+ # Old format: path|||hash (size and source_dir not available)
496
522
  file_size = 0
497
523
  file_hash = parts[1] || ""
524
+ source_dir = ""
498
525
  end
499
526
  else
500
527
  # Oldest format: comment is just the path string
501
528
  file_path = comment
502
529
  file_size = 0
503
530
  file_hash = ""
531
+ source_dir = ""
504
532
  end
505
533
 
506
534
  hash[archive_name] = {
507
535
  path: file_path,
508
536
  size: file_size,
509
- hash: file_hash
537
+ hash: file_hash,
538
+ source_dir: source_dir
510
539
  }
511
540
  end
512
541
  rescue JSON::ParserError => e
data/lib/ruborg/cli.rb CHANGED
@@ -184,8 +184,23 @@ module Ruborg
184
184
  raise
185
185
  end
186
186
 
187
- desc "validate", "Validate configuration file for errors and type issues"
188
- def validate_config
187
+ desc "validate TYPE", "Validate configuration file or repository (TYPE: config or repo)"
188
+ option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower, only for 'repo' type)"
189
+ option :all, type: :boolean, default: false, desc: "Validate all repositories (only for 'repo' type)"
190
+ def validate(type)
191
+ case type
192
+ when "config"
193
+ validate_config_implementation
194
+ when "repo"
195
+ validate_repo_implementation
196
+ else
197
+ raise ConfigError, "Invalid validation type: #{type}. Use 'config' or 'repo'"
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ def validate_config_implementation
189
204
  @logger.info("Validating configuration file: #{options[:config]}")
190
205
  config = Config.new(options[:config])
191
206
 
@@ -201,6 +216,7 @@ module Ruborg
201
216
  errors.concat(validate_boolean_setting(global_settings, "auto_init", "global"))
202
217
  errors.concat(validate_boolean_setting(global_settings, "auto_prune", "global"))
203
218
  errors.concat(validate_boolean_setting(global_settings, "allow_remove_source", "global"))
219
+ errors.concat(validate_boolean_setting(global_settings, "skip_hash_check", "global"))
204
220
 
205
221
  # Validate borg_options booleans
206
222
  if global_settings["borg_options"]
@@ -214,6 +230,7 @@ module Ruborg
214
230
  errors.concat(validate_boolean_setting(repo, "auto_init", repo_name))
215
231
  errors.concat(validate_boolean_setting(repo, "auto_prune", repo_name))
216
232
  errors.concat(validate_boolean_setting(repo, "allow_remove_source", repo_name))
233
+ errors.concat(validate_boolean_setting(repo, "skip_hash_check", repo_name))
217
234
 
218
235
  if repo["borg_options"]
219
236
  warnings.concat(validate_borg_option(repo["borg_options"], "allow_relocated_repo", repo_name))
@@ -257,64 +274,8 @@ module Ruborg
257
274
  raise
258
275
  end
259
276
 
260
- desc "version", "Show ruborg version"
261
- def version
262
- require_relative "version"
263
- puts "ruborg #{Ruborg::VERSION}"
264
- @logger.info("Version checked: #{Ruborg::VERSION}")
265
- end
266
-
267
- desc "metadata ARCHIVE", "Get file metadata from an archive"
268
- option :file, type: :string, desc: "Specific file path (required for standard archives, auto for per-file)"
269
- def metadata(archive_name)
270
- @logger.info("Getting metadata for archive: #{archive_name}")
271
- config = Config.new(options[:config])
272
-
273
- raise ConfigError, "Please specify --repository" unless options[:repository]
274
-
275
- repo_config = config.get_repository(options[:repository])
276
- raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
277
-
278
- global_settings = config.global_settings
279
- merged_config = global_settings.merge(repo_config)
280
- validate_hostname(merged_config)
281
- passphrase = fetch_passphrase_for_repo(merged_config)
282
- borg_opts = merged_config["borg_options"] || {}
283
- borg_path = merged_config["borg_path"]
284
-
285
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
286
- logger: @logger)
287
-
288
- raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
289
-
290
- # Get file metadata
291
- metadata = repo.get_file_metadata(archive_name, file_path: options[:file])
292
-
293
- # Display metadata
294
- puts "\n═══════════════════════════════════════════════════════════════"
295
- puts " FILE METADATA"
296
- puts "═══════════════════════════════════════════════════════════════\n\n"
297
- puts "Archive: #{archive_name}"
298
- puts "File: #{metadata["path"]}"
299
- puts "Size: #{format_size(metadata["size"])}"
300
- puts "Modified: #{metadata["mtime"]}"
301
- puts "Mode: #{metadata["mode"]}"
302
- puts "User: #{metadata["user"]}"
303
- puts "Group: #{metadata["group"]}"
304
- puts "Type: #{metadata["type"]}"
305
- puts ""
306
-
307
- @logger.info("Successfully retrieved metadata for #{metadata["path"]}")
308
- rescue Error => e
309
- @logger.error("Failed to get metadata: #{e.message}")
310
- raise
311
- end
312
-
313
- desc "check", "Check repository integrity and compatibility"
314
- option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
315
- option :all, type: :boolean, default: false, desc: "Check all repositories"
316
- def check
317
- @logger.info("Checking repository compatibility")
277
+ def validate_repo_implementation
278
+ @logger.info("Validating repository compatibility")
318
279
  config = Config.new(options[:config])
319
280
  global_settings = config.global_settings
320
281
  validate_hostname(global_settings)
@@ -323,31 +284,29 @@ module Ruborg
323
284
  borg_version = Repository.borg_version
324
285
  puts "\nBorg version: #{borg_version}\n\n"
325
286
 
326
- repos_to_check = if options[:all]
327
- config.repositories
328
- elsif options[:repository]
329
- repo_config = config.get_repository(options[:repository])
330
- raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
287
+ repos_to_validate = if options[:all]
288
+ config.repositories
289
+ elsif options[:repository]
290
+ repo_config = config.get_repository(options[:repository])
291
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
331
292
 
332
- [repo_config]
333
- else
334
- raise ConfigError, "Please specify --repository or --all"
335
- end
293
+ [repo_config]
294
+ else
295
+ raise ConfigError, "Please specify --repository or --all"
296
+ end
336
297
 
337
- repos_to_check.each do |repo_config|
338
- check_repository(repo_config, global_settings)
298
+ repos_to_validate.each do |repo_config|
299
+ validate_repository(repo_config, global_settings)
339
300
  end
340
301
  rescue Error => e
341
- @logger.error("Check failed: #{e.message}")
302
+ @logger.error("Validation failed: #{e.message}")
342
303
  raise
343
304
  end
344
305
 
345
- private
346
-
347
- def check_repository(repo_config, global_settings)
306
+ def validate_repository(repo_config, global_settings)
348
307
  repo_name = repo_config["name"]
349
- puts "--- Checking repository: #{repo_name} ---"
350
- @logger.info("Checking repository: #{repo_name}")
308
+ puts "--- Validating repository: #{repo_name} ---"
309
+ @logger.info("Validating repository: #{repo_name}")
351
310
 
352
311
  merged_config = global_settings.merge(repo_config)
353
312
  validate_hostname(merged_config)
@@ -392,11 +351,96 @@ module Ruborg
392
351
 
393
352
  puts ""
394
353
  rescue BorgError => e
395
- puts " ✗ Check failed: #{e.message}"
396
- @logger.error("Check failed for #{repo_name}: #{e.message}")
354
+ puts " ✗ Validation failed: #{e.message}"
355
+ @logger.error("Validation failed for #{repo_name}: #{e.message}")
397
356
  puts ""
398
357
  end
399
358
 
359
+ public
360
+
361
+ desc "version", "Show ruborg and borg versions"
362
+ def version
363
+ require_relative "version"
364
+ puts "ruborg #{Ruborg::VERSION}"
365
+ @logger.info("Version checked: #{Ruborg::VERSION}")
366
+
367
+ begin
368
+ borg_version = Repository.borg_version
369
+ borg_path = Repository.borg_path
370
+ puts "borg #{borg_version} (#{borg_path})"
371
+ @logger.info("Borg version: #{borg_version}, path: #{borg_path}")
372
+ rescue BorgError => e
373
+ puts "borg: not found or not executable"
374
+ @logger.warn("Could not determine Borg version: #{e.message}")
375
+ end
376
+ end
377
+
378
+ desc "check", "DEPRECATED: Use 'ruborg validate repo' instead"
379
+ option :verify_data, type: :boolean, default: false, desc: "Verify repository data (slower)"
380
+ option :all, type: :boolean, default: false, desc: "Validate all repositories"
381
+ def check
382
+ puts "\n⚠️ DEPRECATED COMMAND"
383
+ puts "══════════════════════════════════════════════════════════════════\n\n"
384
+ puts "The 'ruborg check' command has been renamed for consistency.\n"
385
+ puts "Please use: ruborg validate repo\n\n"
386
+ puts "Examples:"
387
+ puts " ruborg validate repo --repository documents"
388
+ puts " ruborg validate repo --all"
389
+ puts " ruborg validate repo --repository documents --verify-data\n\n"
390
+ puts "══════════════════════════════════════════════════════════════════\n"
391
+
392
+ @logger.warn("Deprecated command 'check' was called. User should use 'validate repo' instead.")
393
+ exit 1
394
+ end
395
+
396
+ desc "metadata ARCHIVE", "Get file metadata from an archive"
397
+ option :file, type: :string, desc: "Specific file path (required for standard archives, auto for per-file)"
398
+ def metadata(archive_name)
399
+ @logger.info("Getting metadata for archive: #{archive_name}")
400
+ config = Config.new(options[:config])
401
+
402
+ raise ConfigError, "Please specify --repository" unless options[:repository]
403
+
404
+ repo_config = config.get_repository(options[:repository])
405
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
406
+
407
+ global_settings = config.global_settings
408
+ merged_config = global_settings.merge(repo_config)
409
+ validate_hostname(merged_config)
410
+ passphrase = fetch_passphrase_for_repo(merged_config)
411
+ borg_opts = merged_config["borg_options"] || {}
412
+ borg_path = merged_config["borg_path"]
413
+
414
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
415
+ logger: @logger)
416
+
417
+ raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
418
+
419
+ # Get file metadata
420
+ metadata = repo.get_file_metadata(archive_name, file_path: options[:file])
421
+
422
+ # Display metadata
423
+ puts "\n═══════════════════════════════════════════════════════════════"
424
+ puts " FILE METADATA"
425
+ puts "═══════════════════════════════════════════════════════════════\n\n"
426
+ puts "Archive: #{archive_name}"
427
+ puts "File: #{metadata["path"]}"
428
+ puts "Size: #{format_size(metadata["size"])}"
429
+ puts "Modified: #{metadata["mtime"]}"
430
+ puts "Mode: #{metadata["mode"]}"
431
+ puts "User: #{metadata["user"]}"
432
+ puts "Group: #{metadata["group"]}"
433
+ puts "Type: #{metadata["type"]}"
434
+ puts ""
435
+
436
+ @logger.info("Successfully retrieved metadata for #{metadata["path"]}")
437
+ rescue Error => e
438
+ @logger.error("Failed to get metadata: #{e.message}")
439
+ raise
440
+ end
441
+
442
+ private
443
+
400
444
  def show_repositories_summary(config)
401
445
  repositories = config.repositories
402
446
  global_settings = config.global_settings
@@ -578,10 +622,14 @@ module Ruborg
578
622
  end
579
623
  end
580
624
 
625
+ # Get skip_hash_check setting (defaults to false)
626
+ skip_hash_check = merged_config["skip_hash_check"]
627
+ skip_hash_check = false unless skip_hash_check == true
628
+
581
629
  # Create backup config wrapper
582
630
  backup_config = BackupConfig.new(repo_config, merged_config)
583
631
  backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name,
584
- logger: @logger)
632
+ logger: @logger, skip_hash_check: skip_hash_check)
585
633
 
586
634
  archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
587
635
  @logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
data/lib/ruborg/config.rb CHANGED
@@ -41,7 +41,7 @@ module Ruborg
41
41
 
42
42
  def global_settings
43
43
  @data.slice("passbolt", "compression", "encryption", "auto_init", "borg_options", "log_file", "retention",
44
- "auto_prune", "hostname", "allow_remove_source", "borg_path")
44
+ "auto_prune", "hostname", "allow_remove_source", "borg_path", "skip_hash_check")
45
45
  end
46
46
 
47
47
  private
@@ -54,12 +54,12 @@ module Ruborg
54
54
  # Valid configuration keys at each level
55
55
  VALID_GLOBAL_KEYS = %w[
56
56
  hostname compression encryption auto_init auto_prune allow_remove_source
57
- log_file borg_path passbolt borg_options retention repositories
57
+ log_file borg_path passbolt borg_options retention repositories skip_hash_check
58
58
  ].freeze
59
59
 
60
60
  VALID_REPOSITORY_KEYS = %w[
61
61
  name description path hostname retention_mode passbolt retention sources
62
- compression encryption auto_init auto_prune borg_options allow_remove_source
62
+ compression encryption auto_init auto_prune borg_options allow_remove_source skip_hash_check
63
63
  ].freeze
64
64
 
65
65
  VALID_SOURCE_KEYS = %w[name paths exclude].freeze
@@ -122,8 +122,9 @@ module Ruborg
122
122
  errors.concat(validate_boolean_config(@data, "auto_init", "global"))
123
123
  errors.concat(validate_boolean_config(@data, "auto_prune", "global"))
124
124
  errors.concat(validate_boolean_config(@data, "allow_remove_source", "global"))
125
+ errors.concat(validate_boolean_config(@data, "skip_hash_check", "global"))
125
126
 
126
- # Note: borg_options are validated as warnings in CLI validate command, not as errors here
127
+ # NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
127
128
 
128
129
  # Validate global passbolt
129
130
  errors.concat(validate_passbolt_config(@data["passbolt"], "global")) if @data["passbolt"]
@@ -151,6 +152,7 @@ module Ruborg
151
152
  errors.concat(validate_boolean_config(repo, "auto_init", repo_name))
152
153
  errors.concat(validate_boolean_config(repo, "auto_prune", repo_name))
153
154
  errors.concat(validate_boolean_config(repo, "allow_remove_source", repo_name))
155
+ errors.concat(validate_boolean_config(repo, "skip_hash_check", repo_name))
154
156
 
155
157
  # Validate retention_mode
156
158
  if repo["retention_mode"] && !VALID_RETENTION_MODES.include?(repo["retention_mode"])
@@ -158,7 +160,7 @@ module Ruborg
158
160
  "Must be one of: #{VALID_RETENTION_MODES.join(", ")}"
159
161
  end
160
162
 
161
- # Note: borg_options are validated as warnings in CLI validate command, not as errors here
163
+ # NOTE: borg_options are validated as warnings in CLI validate command, not as errors here
162
164
 
163
165
  errors.concat(validate_passbolt_config(repo["passbolt"], repo_name)) if repo["passbolt"]
164
166