ruborg 0.6.2 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 310a71ee8419f02ac2bee2fc03438a9d7b85e838e911202d99bb933bd21564d1
4
- data.tar.gz: c7785d3fa514d20c901bd53b0923d0ccc044fa4fbf69b616009ba1ef16be9451
3
+ metadata.gz: 5b1659a54e64ed15742467c6e95ea96fd311f0332e2fafdee7e560cec5c2385c
4
+ data.tar.gz: ec49de1e1231ad2bd189e08aedb70d22ec10f4f322f1b4690c9c3ec41be590bb
5
5
  SHA512:
6
- metadata.gz: 3532053d345494458c7c4eeee5c79808e73748d95360d71cac880d3db29a14205a4028c8d83aecff4a3fce3db217cf090403053be6fd860609d306271ee9c687
7
- data.tar.gz: 0a22d49b96fb91c79eb3d5ee264f524f8712ca40c61523be12b0186e9a604c0e77e1d8f9507f970edefa21802c0bd74176e6db89c8d75c8234caef25fad0a57a
6
+ metadata.gz: '0593d8521ab13110b3fefe00b9a1bc3cd6a8c28ffa70e93dda6e3bfa6b763f0562abd564af25a14c5619b2fe7dabb7ef76ffbe1341c6dcdb4dcac7efbb7be847'
7
+ data.tar.gz: 9486465c9803962569b5f376556efafa8147d72a294e9a6e3137d29e67c9664b212bed69addc0bab0ad434ddc5f42416e3aa7426087bbf4dd7c9a4ce2c71192f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.1] - 2025-10-08
11
+
12
+ ### Added
13
+ - **Paranoid Mode Duplicate Detection**: Per-file backup mode now uses SHA256 content hashing to detect duplicate files
14
+ - Skips unchanged files automatically (same path, size, and content hash)
15
+ - Creates versioned archives (-v2, -v3) when content changes but modification time stays the same
16
+ - Protects against edge cases where files are modified with manual `touch -t` operations
17
+ - Archive metadata stores: `path|||size|||hash` for comprehensive verification
18
+ - Backward compatible with old archive formats (plain path, path|||hash)
19
+ - **Smart Skip Statistics**: Backup completion messages show both backed-up and skipped file counts
20
+ - Example: "✓ Per-file backup completed: 50000 file(s) backed up, 26456 skipped (unchanged)"
21
+ - Provides visibility into deduplication efficiency
22
+
23
+ ### Fixed
24
+ - **Per-File Backup Archive Collision**: Fixed "Archive already exists" error in per-file backup mode
25
+ - Archives are now verified by path, size, and content hash before skipping
26
+ - Different files with same archive name get automatic version suffixes
27
+ - File size changes detected even when modification time is manually reset
28
+ - Logs warning messages for collision scenarios with detailed context
29
+
30
+ ### Changed
31
+ - **Archive Comment Format**: Per-file archives now store comprehensive metadata
32
+ - New format: `path|||size|||hash` (three-part delimiter-based format)
33
+ - Enables instant duplicate detection without re-hashing files
34
+ - Backward compatible parsing handles old formats gracefully
35
+ - **Enhanced Collision Handling**: Intelligent version suffix generation
36
+ - Appends `-v2`, `-v3`, etc. for archive name collisions
37
+ - Prevents data loss from conflicting archive names
38
+ - Logs warnings for all collision scenarios
39
+
40
+ ### Security
41
+ - **No Security Impact**: Security review found no exploitable vulnerabilities in new features
42
+ - Content hashing uses SHA256 (cryptographically secure)
43
+ - Archive comment parsing uses safe string splitting (no injection risks)
44
+ - File paths from archives only used for comparison, not file operations
45
+ - Array-based command execution prevents shell injection
46
+ - JSON parsing uses Ruby's safe `JSON.parse()` with error handling
47
+ - All existing security controls maintained
48
+
49
+ ## [0.7.0] - 2025-10-08
50
+
51
+ ### Added
52
+ - **List Files in Archives**: New `--archive` option for list command to view files within a specific archive
53
+ - `ruborg list --repository documents --archive archive-name`
54
+ - Lists all files and directories contained in the specified archive
55
+ - Useful for finding specific files before restore operations
56
+ - **File Metadata Retrieval**: New `metadata` command to retrieve detailed file information from archives
57
+ - `ruborg metadata ARCHIVE --repository documents --file /path/to/file`
58
+ - Auto-detects per-file archives and retrieves metadata without --file option
59
+ - Displays file size (human-readable), modification time, permissions, owner, group, and type
60
+ - Supports both standard and per-file archive modes
61
+ - **Version Command**: New `ruborg version` command to display current ruborg version
62
+ - **Enhanced Archive Naming**: Per-file archives now include actual filename in archive name
63
+ - Changed from `repo-hash-timestamp` to `repo-filename-hash-timestamp`
64
+ - Makes archive names human-readable and easier to identify
65
+ - Automatic filename sanitization (alphanumeric, dash, underscore, dot only)
66
+ - **Smart Filename Truncation**: Archive names limited to 255 characters (filesystem limit)
67
+ - Intelligent truncation preserves file extensions when possible
68
+ - Handles very long filenames and repository names gracefully
69
+ - Example: `very-long-name...truncated.sql` becomes `very-lon.sql` with hash and timestamp
70
+ - **File Modification Time in Archives**: Per-file mode uses file mtime instead of backup time
71
+ - Archive timestamps reflect when files were last modified, not when backup ran
72
+ - More accurate for tracking file changes over time
73
+ - Enables better retention based on actual file activity
74
+
75
+ ### Changed
76
+ - **Separated Console Output from Logs**: Console now shows progress, logs show results
77
+ - Console displays repository headers, progress indicators, and completion messages
78
+ - Logs contain structured operational data with timestamps
79
+ - Repository name appears in both console headers and log entries (format: `[repo_name]`)
80
+ - Cleaner separation between user feedback and audit trails
81
+ - **Enhanced Logging**: More detailed logging for backup operations
82
+ - Standard mode: Logs archive name with source count
83
+ - Per-file mode: Logs each file with its archive name
84
+ - Repository name prefix in all log entries for multi-repo clarity
85
+
86
+ ### Security
87
+ - **Archive Name Validation**: Enhanced sanitization for archive names containing filenames
88
+ - Whitelist approach allows only safe characters: `[a-zA-Z0-9._-]`
89
+ - Replaces unsafe characters with underscores
90
+ - Prevents injection attacks via malicious filenames
91
+ - Archive names still passed as array elements to prevent shell injection
92
+ - **Path Normalization**: Improved file path handling in metadata retrieval
93
+ - Correctly handles borg's path format (strips leading slash)
94
+ - Safe matching within JSON data from borg
95
+ - No path traversal vulnerabilities introduced
96
+
10
97
  ## [0.6.2] - 2025-10-08
11
98
 
12
99
  ### Fixed
data/README.md CHANGED
@@ -25,7 +25,7 @@ A friendly Ruby frontend for [Borg Backup](https://www.borgbackup.org/). Ruborg
25
25
  - 📈 **Summary View** - Quick overview of all repositories and their configurations
26
26
  - 🔧 **Custom Borg Path** - Support for custom Borg executable paths per repository
27
27
  - 🏠 **Hostname Validation** - NEW! Restrict backups to specific hosts (global or per-repository)
28
- - ✅ **Well-tested** - Comprehensive test suite with RSpec (220 examples, 0 failures)
28
+ - ✅ **Well-tested** - Comprehensive test suite with RSpec (286+ examples)
29
29
  - 🔒 **Security-focused** - Path validation, safe YAML loading, command injection protection
30
30
 
31
31
  ## Prerequisites
@@ -401,8 +401,11 @@ Current value: "true" (String). Set 'allow_remove_source: true' in configuration
401
401
  ### List Archives
402
402
 
403
403
  ```bash
404
- # List archives for a specific repository
404
+ # List all archives for a specific repository
405
405
  ruborg list --repository documents
406
+
407
+ # List files within a specific archive
408
+ ruborg list --repository documents --archive archive-name
406
409
  ```
407
410
 
408
411
  ### Restore from Archive
@@ -434,6 +437,40 @@ The `info` command without `--repository` displays a summary showing:
434
437
  - Retention policies (global and per-repository overrides)
435
438
  - Number of sources per repository
436
439
 
440
+ ### Get File Metadata from Archives
441
+
442
+ ```bash
443
+ # Get metadata from per-file archive (auto-detects single file)
444
+ ruborg metadata archive-name --repository documents
445
+
446
+ # Get metadata for specific file in standard archive
447
+ ruborg metadata archive-name --repository documents --file /path/to/file.txt
448
+ ```
449
+
450
+ The `metadata` command displays detailed file information:
451
+ - File path
452
+ - Size (human-readable format)
453
+ - Modification time
454
+ - File permissions (mode)
455
+ - Owner and group
456
+ - File type
457
+
458
+ **Example output:**
459
+ ```
460
+ ═══════════════════════════════════════════════════════════════
461
+ FILE METADATA
462
+ ═══════════════════════════════════════════════════════════════
463
+
464
+ Archive: databases-backup.sql-8b4c26d05aae-2025-10-08_19-05-07
465
+ File: var/backups/database.sql
466
+ Size: 45.67 MB
467
+ Modified: 2025-10-08T19:05:07.123456
468
+ Mode: -rw-r--r--
469
+ User: postgres
470
+ Group: postgres
471
+ Type: regular file
472
+ ```
473
+
437
474
  ### Check Repository Compatibility
438
475
 
439
476
  ```bash
@@ -468,6 +505,13 @@ Borg version: 1.2.8
468
505
  Please upgrade Borg or migrate the repository
469
506
  ```
470
507
 
508
+ ### Show Version
509
+
510
+ ```bash
511
+ # Display ruborg version
512
+ ruborg version
513
+ ```
514
+
471
515
  ## Passbolt Integration
472
516
 
473
517
  Ruborg can retrieve encryption passphrases from Passbolt using the Passbolt CLI:
@@ -610,10 +654,12 @@ See [SECURITY.md](SECURITY.md) for detailed security information and best practi
610
654
  | `init REPOSITORY` | Initialize a new Borg repository | `--passphrase`, `--passbolt-id`, `--log` |
611
655
  | `validate` | Validate configuration file for type errors | `--config`, `--log` |
612
656
  | `backup` | Create a backup using config file | `--config`, `--repository`, `--all`, `--name`, `--remove-source`, `--log` |
613
- | `list` | List all archives in repository | `--config`, `--repository`, `--log` |
657
+ | `list` | List archives or files in repository | `--config`, `--repository`, `--archive`, `--log` |
614
658
  | `restore ARCHIVE` | Restore files from archive | `--config`, `--repository`, `--destination`, `--path`, `--log` |
659
+ | `metadata ARCHIVE` | Get file metadata from archive | `--config`, `--repository`, `--file`, `--log` |
615
660
  | `info` | Show repository information | `--config`, `--repository`, `--log` |
616
661
  | `check` | Check repository integrity and compatibility | `--config`, `--repository`, `--all`, `--verify-data`, `--log` |
662
+ | `version` | Show ruborg version | None |
617
663
 
618
664
  ### Options
619
665
 
@@ -729,9 +775,12 @@ repositories:
729
775
 
730
776
  **How it works:**
731
777
  - **Per-File Archives**: Each file is backed up as a separate Borg archive
732
- - **Hash-Based Naming**: Archives are named `repo-{hash}-{timestamp}` (hash uniquely identifies the file path)
733
- - **Original Path Stored**: The complete original file path is stored in the archive comment
778
+ - **Hash-Based Naming**: Archives are named `repo-filename-{hash}-{timestamp}` (hash uniquely identifies the file path)
779
+ - **Metadata Storage**: Archive comments store `path|||size|||hash` for comprehensive duplicate detection
734
780
  - **Metadata Preservation**: Borg preserves all file metadata (mtime, size, permissions) in the archive
781
+ - **Paranoid Mode Duplicate Detection** (v0.7.1+): SHA256 content hashing detects file changes even when size and mtime are identical
782
+ - **Smart Skip**: Automatically skips unchanged files during backup (compares path, size, and content hash)
783
+ - **Version Suffixes**: Creates versioned archives (`-v2`, `-v3`) for archive name collisions, preventing data loss
735
784
  - **Smart Pruning**: Retention reads file mtime directly from archives - works even after files are deleted
736
785
 
737
786
  **File Metadata Retention Options:**
data/SECURITY.md CHANGED
@@ -229,6 +229,32 @@ We will respond within 48 hours and work with you to address the issue.
229
229
 
230
230
  ## Security Audit History
231
231
 
232
+ - **v0.7.1** (2025-10-08): Paranoid mode duplicate detection - security review passed
233
+ - **NEW FEATURE**: SHA256 content hashing for detecting file changes even when mtime/size are identical
234
+ - **NEW FEATURE**: Smart skip statistics showing backed-up and skipped file counts
235
+ - **BUG FIX**: Fixed "Archive already exists" error in per-file backup mode
236
+ - **ENHANCED**: Archive comment format now stores comprehensive metadata (`path|||size|||hash`)
237
+ - **ENHANCED**: Version suffix generation for archive name collisions (`-v2`, `-v3`)
238
+ - **SECURITY REVIEW**: Comprehensive security analysis found no exploitable vulnerabilities
239
+ - SHA256 hashing is cryptographically secure (using Ruby's Digest::SHA256)
240
+ - Archive comment parsing uses safe string splitting with `|||` delimiter (no injection risks)
241
+ - File paths from archives only used for comparison, never for file operations
242
+ - Array-based command execution prevents shell injection (maintained from previous versions)
243
+ - JSON parsing uses Ruby's safe `JSON.parse()` with error handling
244
+ - All existing security controls maintained - no security regressions
245
+ - Backward compatibility with three metadata formats (plain path, path|||hash, path|||size|||hash)
246
+
247
+ - **v0.7.0** (2025-10-08): Archive naming and metadata features - security review passed
248
+ - **NEW FEATURE**: List files within archives (--archive option)
249
+ - **NEW FEATURE**: File metadata retrieval from archives
250
+ - **NEW FEATURE**: Enhanced archive naming with filenames in per-file mode
251
+ - **SECURITY REVIEW**: Comprehensive security analysis found no exploitable vulnerabilities
252
+ - Archive name sanitization uses whitelist approach `[a-zA-Z0-9._-]`
253
+ - Array-based command execution prevents shell injection
254
+ - Safe JSON parsing without deserialization risks
255
+ - Path normalization handles borg's format safely (strips leading slash for matching only)
256
+ - All new features maintain existing security controls
257
+
232
258
  - **v0.6.1** (2025-10-08): Enhanced logging with sensitive data protection
233
259
  - **NEW FEATURE**: Comprehensive logging for backup operations, restoration, and deletion
234
260
  - Passwords and passphrases are NEVER logged (neither CLI nor Passbolt passwords)
data/lib/ruborg/backup.rb CHANGED
@@ -25,41 +25,125 @@ module Ruborg
25
25
 
26
26
  def create_standard_archive(name, remove_source)
27
27
  archive_name = name || Time.now.strftime("%Y-%m-%d_%H-%M-%S")
28
+
29
+ # Show repository header in console only
30
+ print_repository_header
31
+
32
+ # Show progress in console
33
+ puts "Creating archive: #{archive_name}"
34
+
28
35
  cmd = build_create_command(archive_name)
29
36
 
30
37
  execute_borg_command(cmd)
31
38
 
39
+ # Log successful action
40
+ @logger&.info("[#{@repo_name}] Created archive #{archive_name} with #{@config.backup_paths.size} source(s)")
41
+ puts "✓ Archive created successfully"
42
+
32
43
  remove_source_files if remove_source
33
44
  end
34
45
 
46
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
35
47
  def create_per_file_archives(name_prefix, remove_source)
36
48
  # Collect all files from backup paths
37
49
  files_to_backup = collect_files_from_paths(@config.backup_paths, @config.exclude_patterns)
38
50
 
39
51
  raise BorgError, "No files found to backup" if files_to_backup.empty?
40
52
 
41
- @logger&.info("Per-file mode: Found #{files_to_backup.size} file(s) to backup")
53
+ # Get list of existing archives for duplicate detection
54
+ existing_archives = get_existing_archive_names
42
55
 
43
- timestamp = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
56
+ # Show repository header in console only
57
+ print_repository_header
44
58
 
59
+ puts "Found #{files_to_backup.size} file(s) to backup"
60
+
61
+ backed_up_count = 0
62
+ skipped_count = 0
63
+
64
+ # rubocop:disable Metrics/BlockLength
45
65
  files_to_backup.each_with_index do |file_path, index|
46
- # Generate hash-based archive name
66
+ # Generate hash-based archive name with filename
47
67
  path_hash = generate_path_hash(file_path)
48
- archive_name = name_prefix || "#{@repo_name}-#{path_hash}-#{timestamp}"
49
-
50
- @logger&.info("Backing up file #{index + 1}/#{files_to_backup.size}: #{file_path}")
68
+ filename = File.basename(file_path)
69
+ sanitized_filename = sanitize_filename(filename)
70
+
71
+ # Use file modification time for timestamp (not backup creation time)
72
+ file_mtime = File.mtime(file_path).strftime("%Y-%m-%d_%H-%M-%S")
73
+
74
+ # Ensure archive name doesn't exceed 255 characters (filesystem limit)
75
+ archive_name = name_prefix || build_archive_name(@repo_name, sanitized_filename, path_hash, file_mtime)
76
+
77
+ # Show progress in console
78
+ print " [#{index + 1}/#{files_to_backup.size}] Backing up: #{file_path}"
79
+
80
+ # Check if archive already exists AND contains this exact file
81
+ if existing_archives.key?(archive_name)
82
+ stored_info = existing_archives[archive_name]
83
+ if stored_info[:path] == file_path
84
+ # Same file, same mtime -> check if size changed (rare: manual content edit + touch -t)
85
+ current_size = File.size(file_path)
86
+ stored_size = stored_info[:size]
87
+
88
+ 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
95
+ puts " - Archive already exists (file unchanged)"
96
+ @logger&.info(
97
+ "[#{@repo_name}] Skipped #{file_path} - archive #{archive_name} already exists (file unchanged)"
98
+ )
99
+ skipped_count += 1
100
+ next
101
+ else
102
+ # Size same but content changed (rare: edited + truncated/padded to same size)
103
+ archive_name = find_next_version_name(archive_name, existing_archives)
104
+ @logger&.warn(
105
+ "[#{@repo_name}] File content changed but size/mtime unchanged for #{file_path}, " \
106
+ "using #{archive_name}"
107
+ )
108
+ end
109
+ else
110
+ # Size changed but mtime same -> content changed, add version suffix
111
+ archive_name = find_next_version_name(archive_name, existing_archives)
112
+ @logger&.warn(
113
+ "[#{@repo_name}] File size changed but mtime unchanged for #{file_path}, using #{archive_name}"
114
+ )
115
+ end
116
+ else
117
+ # Different file, same archive name -> add version suffix
118
+ archive_name = find_next_version_name(archive_name, existing_archives)
119
+ @logger&.warn(
120
+ "[#{@repo_name}] Archive name collision: #{archive_name} exists for different file, using version suffix"
121
+ )
122
+ end
123
+ end
51
124
 
52
125
  # Create archive for single file with original path as comment
53
126
  cmd = build_per_file_create_command(archive_name, file_path)
54
127
 
55
128
  execute_borg_command(cmd)
129
+ puts ""
130
+
131
+ # Log successful action with details
132
+ @logger&.info("[#{@repo_name}] Archived #{file_path} in archive #{archive_name}")
133
+ backed_up_count += 1
56
134
  end
135
+ # rubocop:enable Metrics/BlockLength
57
136
 
58
- @logger&.info("Per-file backup completed: #{files_to_backup.size} file(s) backed up")
137
+ if skipped_count.positive?
138
+ puts "✓ Per-file backup completed: #{backed_up_count} file(s) backed up, #{skipped_count} skipped (unchanged)"
139
+ else
140
+ puts "✓ Per-file backup completed: #{backed_up_count} file(s) backed up"
141
+ end
59
142
 
60
143
  # NOTE: remove_source handled per file after successful backup
61
144
  remove_source_files if remove_source
62
145
  end
146
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
63
147
 
64
148
  def collect_files_from_paths(paths, exclude_patterns)
65
149
  require "find"
@@ -97,12 +181,79 @@ module Ruborg
97
181
  Digest::SHA256.hexdigest(file_path)[0...12]
98
182
  end
99
183
 
184
+ def sanitize_filename(filename)
185
+ # Remove or replace characters that are not safe for archive names
186
+ # Allow alphanumeric, dash, underscore, and dot
187
+ sanitized = filename.gsub(/[^a-zA-Z0-9._-]/, "_")
188
+
189
+ # Ensure the sanitized name is not empty
190
+ sanitized = "file" if sanitized.empty? || sanitized.strip.empty?
191
+
192
+ sanitized
193
+ end
194
+
195
+ def build_archive_name(repo_name, sanitized_filename, path_hash, timestamp)
196
+ # Maximum filename length for most filesystems (ext4, NTFS, APFS)
197
+ max_length = 255
198
+
199
+ # Calculate fixed portions: separators (3) + hash (12) + timestamp (19)
200
+ fixed_length = 3 + path_hash.length + timestamp.length
201
+ repo_name_length = repo_name ? repo_name.length : 0
202
+
203
+ # Calculate available space for filename
204
+ available_for_filename = max_length - fixed_length - repo_name_length
205
+
206
+ # Truncate filename if necessary, preserving file extension if possible
207
+ truncated_filename = if sanitized_filename.length > available_for_filename
208
+ truncate_with_extension(sanitized_filename, available_for_filename)
209
+ else
210
+ sanitized_filename
211
+ end
212
+
213
+ "#{repo_name}-#{truncated_filename}-#{path_hash}-#{timestamp}"
214
+ end
215
+
216
+ def truncate_with_extension(filename, max_length)
217
+ return "" if max_length <= 0
218
+ return filename if filename.length <= max_length
219
+
220
+ # Try to preserve extension (last .xxx)
221
+ if filename.include?(".") && filename !~ /^\./
222
+ parts = filename.rpartition(".")
223
+ basename = parts[0]
224
+ extension = parts[2]
225
+
226
+ # Reserve space for extension plus dot
227
+ extension_length = extension.length + 1
228
+
229
+ if extension_length < max_length
230
+ basename_max = max_length - extension_length
231
+ "#{basename[0...basename_max]}.#{extension}"
232
+ else
233
+ # Extension too long, just truncate entire filename
234
+ filename[0...max_length]
235
+ end
236
+ else
237
+ # No extension, just truncate
238
+ filename[0...max_length]
239
+ end
240
+ end
241
+
242
+ def calculate_file_hash(file_path)
243
+ require "digest"
244
+ Digest::SHA256.file(file_path).hexdigest
245
+ end
246
+
100
247
  def build_per_file_create_command(archive_name, file_path)
101
248
  cmd = [@repository.borg_path, "create"]
102
249
  cmd += ["--compression", @config.compression]
103
250
 
104
- # Store original path in archive comment for retrieval
105
- cmd += ["--comment", file_path]
251
+ # Store file metadata (path + size + hash) in archive comment for duplicate detection
252
+ # Format: path|||size|||hash (using ||| as delimiter to avoid conflicts with paths)
253
+ file_size = File.size(file_path)
254
+ file_hash = calculate_file_hash(file_path)
255
+ metadata = "#{file_path}|||#{file_size}|||#{file_hash}"
256
+ cmd += ["--comment", metadata]
106
257
 
107
258
  cmd << "#{@repository.path}::#{archive_name}"
108
259
  cmd << file_path
@@ -250,5 +401,95 @@ module Ruborg
250
401
  File.expand_path(path)
251
402
  end
252
403
  end
404
+
405
+ def print_repository_header
406
+ puts "\n#{"=" * 60}"
407
+ puts " Repository: #{@repo_name}"
408
+ puts "=" * 60
409
+ end
410
+
411
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
412
+ def get_existing_archive_names
413
+ require "json"
414
+ require "open3"
415
+
416
+ # First get list of archives
417
+ cmd = [@repository.borg_path, "list", @repository.path, "--json"]
418
+ env = {}
419
+ passphrase = @repository.instance_variable_get(:@passphrase)
420
+ env["BORG_PASSPHRASE"] = passphrase if passphrase
421
+ env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = "yes"
422
+ env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = "yes"
423
+
424
+ stdout, stderr, status = Open3.capture3(env, *cmd)
425
+ raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
426
+
427
+ json_data = JSON.parse(stdout)
428
+ archives = json_data["archives"] || []
429
+
430
+ # Build hash by querying each archive individually for comment
431
+ # This is necessary because 'borg list' doesn't include comments
432
+ archives.each_with_object({}) do |archive, hash|
433
+ archive_name = archive["name"]
434
+
435
+ # Query this specific archive to get the comment
436
+ info_cmd = [@repository.borg_path, "info", "#{@repository.path}::#{archive_name}", "--json"]
437
+ info_stdout, _, info_status = Open3.capture3(env, *info_cmd)
438
+
439
+ unless info_status.success?
440
+ # If we can't get info for this archive, skip it with defaults
441
+ hash[archive_name] = { path: "", size: 0, hash: "" }
442
+ next
443
+ end
444
+
445
+ info_data = JSON.parse(info_stdout)
446
+ archive_info = info_data["archives"]&.first || {}
447
+ comment = archive_info["comment"] || ""
448
+
449
+ # Parse comment based on format
450
+ # The comment field stores metadata as: path|||size|||hash (using ||| as delimiter)
451
+ # For backward compatibility, handle old formats:
452
+ # - Old format 1: plain path (no |||)
453
+ # - Old format 2: path|||hash (2 parts)
454
+ # - New format: path|||size|||hash (3 parts)
455
+ if comment.include?("|||")
456
+ parts = comment.split("|||")
457
+ file_path = parts[0]
458
+ if parts.length >= 3
459
+ # New format: path|||size|||hash
460
+ file_size = parts[1].to_i
461
+ file_hash = parts[2] || ""
462
+ else
463
+ # Old format: path|||hash (size not available)
464
+ file_size = 0
465
+ file_hash = parts[1] || ""
466
+ end
467
+ else
468
+ # Oldest format: comment is just the path string
469
+ file_path = comment
470
+ file_size = 0
471
+ file_hash = ""
472
+ end
473
+
474
+ hash[archive_name] = {
475
+ path: file_path,
476
+ size: file_size,
477
+ hash: file_hash
478
+ }
479
+ end
480
+ rescue JSON::ParserError => e
481
+ raise BorgError, "Failed to parse archive info: #{e.message}"
482
+ end
483
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
484
+
485
+ def find_next_version_name(base_name, existing_archives)
486
+ version = 2
487
+ loop do
488
+ versioned_name = "#{base_name}-v#{version}"
489
+ return versioned_name unless existing_archives.key?(versioned_name)
490
+
491
+ version += 1
492
+ end
493
+ end
253
494
  end
254
495
  end
data/lib/ruborg/cli.rb CHANGED
@@ -60,9 +60,9 @@ module Ruborg
60
60
  raise
61
61
  end
62
62
 
63
- desc "list", "List all archives in the repository"
63
+ desc "list", "List all archives in the repository or files in a specific archive"
64
+ option :archive, type: :string, desc: "Archive name to list files from"
64
65
  def list
65
- @logger.info("Listing archives in repository")
66
66
  config = Config.new(options[:config])
67
67
 
68
68
  raise ConfigError, "Please specify --repository" unless options[:repository]
@@ -77,7 +77,8 @@ module Ruborg
77
77
  borg_opts = merged_config["borg_options"] || {}
78
78
  borg_path = merged_config["borg_path"]
79
79
 
80
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path, logger: @logger)
80
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
81
+ logger: @logger)
81
82
 
82
83
  # Auto-initialize repository if configured
83
84
  # Use strict boolean checking: only true enables, everything else disables
@@ -89,10 +90,17 @@ module Ruborg
89
90
  puts "Repository auto-initialized at #{repo_config["path"]}"
90
91
  end
91
92
 
92
- repo.list
93
- @logger.info("Successfully listed archives")
93
+ if options[:archive]
94
+ @logger.info("Listing files in archive: #{options[:archive]}")
95
+ repo.list_archive(options[:archive])
96
+ @logger.info("Successfully listed files in archive")
97
+ else
98
+ @logger.info("Listing archives in repository")
99
+ repo.list
100
+ @logger.info("Successfully listed archives")
101
+ end
94
102
  rescue Error => e
95
- @logger.error("Failed to list archives: #{e.message}")
103
+ @logger.error("Failed to list: #{e.message}")
96
104
  raise
97
105
  end
98
106
 
@@ -116,7 +124,8 @@ module Ruborg
116
124
  borg_opts = merged_config["borg_options"] || {}
117
125
  borg_path = merged_config["borg_path"]
118
126
 
119
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path, logger: @logger)
127
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
128
+ logger: @logger)
120
129
 
121
130
  # Create backup config wrapper for compatibility
122
131
  backup_config = BackupConfig.new(repo_config, merged_config)
@@ -155,7 +164,8 @@ module Ruborg
155
164
  borg_opts = merged_config["borg_options"] || {}
156
165
  borg_path = merged_config["borg_path"]
157
166
 
158
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path, logger: @logger)
167
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
168
+ logger: @logger)
159
169
 
160
170
  # Auto-initialize repository if configured
161
171
  # Use strict boolean checking: only true enables, everything else disables
@@ -234,7 +244,8 @@ module Ruborg
234
244
 
235
245
  if errors.any?
236
246
  puts "Configuration has errors that must be fixed.\n\n"
237
- raise ConfigError, "Configuration validation failed"
247
+ @logger.error("Configuration validation failed")
248
+ exit 1
238
249
  else
239
250
  puts "Configuration is valid but has warnings.\n\n"
240
251
  end
@@ -246,76 +257,57 @@ module Ruborg
246
257
  raise
247
258
  end
248
259
 
249
- desc "validate", "Validate configuration file for errors and type issues"
250
- def validate_config
251
- @logger.info("Validating configuration file: #{options[:config]}")
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}")
252
271
  config = Config.new(options[:config])
253
272
 
254
- puts "\n═══════════════════════════════════════════════════════════════"
255
- puts " CONFIGURATION VALIDATION"
256
- puts "═══════════════════════════════════════════════════════════════\n\n"
273
+ raise ConfigError, "Please specify --repository" unless options[:repository]
257
274
 
258
- errors = []
259
- warnings = []
275
+ repo_config = config.get_repository(options[:repository])
276
+ raise ConfigError, "Repository '#{options[:repository]}' not found" unless repo_config
260
277
 
261
- # Validate global boolean settings
262
278
  global_settings = config.global_settings
263
- errors.concat(validate_boolean_setting(global_settings, "auto_init", "global"))
264
- errors.concat(validate_boolean_setting(global_settings, "auto_prune", "global"))
265
- errors.concat(validate_boolean_setting(global_settings, "allow_remove_source", "global"))
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"]
266
284
 
267
- # Validate borg_options booleans
268
- if global_settings["borg_options"]
269
- warnings.concat(validate_borg_option(global_settings["borg_options"], "allow_relocated_repo", "global"))
270
- warnings.concat(validate_borg_option(global_settings["borg_options"], "allow_unencrypted_repo", "global"))
271
- end
285
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
286
+ logger: @logger)
272
287
 
273
- # Validate per-repository settings
274
- config.repositories.each do |repo|
275
- repo_name = repo["name"]
276
- errors.concat(validate_boolean_setting(repo, "auto_init", repo_name))
277
- errors.concat(validate_boolean_setting(repo, "auto_prune", repo_name))
278
- errors.concat(validate_boolean_setting(repo, "allow_remove_source", repo_name))
288
+ raise BorgError, "Repository does not exist at #{repo_config["path"]}" unless repo.exists?
279
289
 
280
- if repo["borg_options"]
281
- warnings.concat(validate_borg_option(repo["borg_options"], "allow_relocated_repo", repo_name))
282
- warnings.concat(validate_borg_option(repo["borg_options"], "allow_unencrypted_repo", repo_name))
283
- end
284
- end
290
+ # Get file metadata
291
+ metadata = repo.get_file_metadata(archive_name, file_path: options[:file])
285
292
 
286
- # Display results
287
- if errors.empty? && warnings.empty?
288
- puts " Configuration is valid"
289
- puts " No type errors or warnings found\n\n"
290
- else
291
- unless errors.empty?
292
- puts " ERRORS FOUND (#{errors.size}):"
293
- errors.each do |error|
294
- puts " - #{error}"
295
- end
296
- puts ""
297
- end
298
-
299
- unless warnings.empty?
300
- puts "⚠️ WARNINGS (#{warnings.size}):"
301
- warnings.each do |warning|
302
- puts " - #{warning}"
303
- end
304
- puts ""
305
- end
306
-
307
- if errors.any?
308
- puts "Configuration has errors that must be fixed.\n\n"
309
- exit 1
310
- else
311
- puts "Configuration is valid but has warnings.\n\n"
312
- end
313
- end
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 ""
314
306
 
315
- @logger.info("Configuration validation completed")
307
+ @logger.info("Successfully retrieved metadata for #{metadata["path"]}")
316
308
  rescue Error => e
317
- @logger.error("Validation failed: #{e.message}")
318
- error_exit(e)
309
+ @logger.error("Failed to get metadata: #{e.message}")
310
+ raise
319
311
  end
320
312
 
321
313
  desc "check", "Check repository integrity and compatibility"
@@ -363,7 +355,8 @@ module Ruborg
363
355
  borg_opts = merged_config["borg_options"] || {}
364
356
  borg_path = merged_config["borg_path"]
365
357
 
366
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path, logger: @logger)
358
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
359
+ logger: @logger)
367
360
 
368
361
  unless repo.exists?
369
362
  puts " ✗ Repository does not exist at #{repo_config["path"]}"
@@ -480,6 +473,21 @@ module Ruborg
480
473
  parts.empty? ? "none" : parts.join(", ")
481
474
  end
482
475
 
476
+ def format_size(bytes)
477
+ return "0 B" if bytes.nil? || bytes.zero?
478
+
479
+ units = %w[B KB MB GB TB]
480
+ size = bytes.to_f
481
+ unit_index = 0
482
+
483
+ while size >= 1024 && unit_index < units.length - 1
484
+ size /= 1024.0
485
+ unit_index += 1
486
+ end
487
+
488
+ format("%.2f %s", size, units[unit_index])
489
+ end
490
+
483
491
  def get_passphrase(passphrase, passbolt_id)
484
492
  return passphrase if passphrase
485
493
  return Passbolt.new(resource_id: passbolt_id, logger: @logger).get_password if passbolt_id
@@ -543,7 +551,8 @@ module Ruborg
543
551
  passphrase = fetch_passphrase_for_repo(merged_config)
544
552
  borg_opts = merged_config["borg_options"] || {}
545
553
  borg_path = merged_config["borg_path"]
546
- repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path, logger: @logger)
554
+ repo = Repository.new(repo_config["path"], passphrase: passphrase, borg_options: borg_opts, borg_path: borg_path,
555
+ logger: @logger)
547
556
 
548
557
  # Auto-initialize if configured
549
558
  # Use strict boolean checking: only true enables, everything else disables
@@ -571,7 +580,8 @@ module Ruborg
571
580
 
572
581
  # Create backup config wrapper
573
582
  backup_config = BackupConfig.new(repo_config, merged_config)
574
- backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name, logger: @logger)
583
+ backup = Backup.new(repo, config: backup_config, retention_mode: retention_mode, repo_name: repo_name,
584
+ logger: @logger)
575
585
 
576
586
  archive_name = options[:name] ? sanitize_archive_name(options[:name]) : nil
577
587
  @logger.info("Creating archive#{"s" if retention_mode == "per_file"}: #{archive_name || "auto-generated"}")
@@ -41,6 +41,95 @@ module Ruborg
41
41
  execute_borg_command(cmd)
42
42
  end
43
43
 
44
+ def list_archive(archive_name)
45
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
46
+ raise BorgError, "Archive name cannot be empty" if archive_name.nil? || archive_name.strip.empty?
47
+
48
+ cmd = [@borg_path, "list", "#{@path}::#{archive_name}"]
49
+ execute_borg_command(cmd)
50
+ end
51
+
52
+ def get_archive_info(archive_name)
53
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
54
+ raise BorgError, "Archive name cannot be empty" if archive_name.nil? || archive_name.strip.empty?
55
+
56
+ require "json"
57
+ require "open3"
58
+
59
+ cmd = [@borg_path, "info", "#{@path}::#{archive_name}", "--json"]
60
+ env = build_borg_env
61
+
62
+ stdout, stderr, status = Open3.capture3(env, *cmd)
63
+ raise BorgError, "Failed to get archive info: #{stderr}" unless status.success?
64
+
65
+ JSON.parse(stdout)
66
+ rescue JSON::ParserError => e
67
+ raise BorgError, "Failed to parse archive info: #{e.message}"
68
+ end
69
+
70
+ def get_file_metadata(archive_name, file_path: nil)
71
+ raise BorgError, "Repository does not exist at #{@path}" unless exists?
72
+ raise BorgError, "Archive name cannot be empty" if archive_name.nil? || archive_name.strip.empty?
73
+
74
+ require "json"
75
+ require "open3"
76
+
77
+ # Get archive info to check if it's a per-file archive
78
+ archive_info = get_archive_info(archive_name)
79
+ comment = archive_info.dig("archives", 0, "comment")
80
+
81
+ # If it's a per-file archive (has comment with original path), get metadata for that file
82
+ # Otherwise, require file_path parameter
83
+ if comment && !comment.empty?
84
+ # Per-file archive - get metadata for the single file
85
+ get_file_metadata_from_archive(archive_name, nil)
86
+ else
87
+ # Standard archive - require file_path
88
+ raise BorgError, "file_path parameter required for standard archives" if file_path.nil? || file_path.empty?
89
+
90
+ get_file_metadata_from_archive(archive_name, file_path)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def get_file_metadata_from_archive(archive_name, file_path)
97
+ require "json"
98
+ require "open3"
99
+
100
+ cmd = [@borg_path, "list", "#{@path}::#{archive_name}", "--json-lines"]
101
+ env = build_borg_env
102
+
103
+ stdout, stderr, status = Open3.capture3(env, *cmd)
104
+ raise BorgError, "Failed to list archive contents: #{stderr}" unless status.success?
105
+
106
+ # Parse JSON lines
107
+ files = stdout.lines.map do |line|
108
+ JSON.parse(line)
109
+ end
110
+
111
+ # If file_path specified, find that specific file
112
+ if file_path
113
+ # Borg stores absolute paths by stripping the leading slash
114
+ # For example: /var/folders/foo -> var/folders/foo
115
+ # Try both the original path and the path with leading slash removed
116
+ normalized_path = file_path.start_with?("/") ? file_path[1..] : file_path
117
+ file_metadata = files.find { |f| f["path"] == file_path || f["path"] == normalized_path }
118
+ raise BorgError, "File '#{file_path}' not found in archive" unless file_metadata
119
+
120
+ file_metadata
121
+ else
122
+ # Per-file archive - return metadata for the single file (first file)
123
+ raise BorgError, "Archive appears to be empty" if files.empty?
124
+
125
+ files.first
126
+ end
127
+ rescue JSON::ParserError => e
128
+ raise BorgError, "Failed to parse file metadata: #{e.message}"
129
+ end
130
+
131
+ public
132
+
44
133
  def prune(retention_policy = {}, retention_mode: "standard")
45
134
  raise BorgError, "Repository does not exist at #{@path}" unless exists?
46
135
  raise BorgError, "No retention policy specified" if retention_policy.nil? || retention_policy.empty?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruborg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michail Pantelelis
@@ -149,7 +149,6 @@ files:
149
149
  - lib/ruborg/passbolt.rb
150
150
  - lib/ruborg/repository.rb
151
151
  - lib/ruborg/version.rb
152
- - ruborg.gemspec
153
152
  - ruborg.yml.example
154
153
  homepage: https://github.com/mpantel/ruborg
155
154
  licenses:
data/ruborg.gemspec DELETED
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/ruborg/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "ruborg"
7
- spec.version = Ruborg::VERSION
8
- spec.authors = ["Michail Pantelelis"]
9
- spec.email = ["mpantel@aegean.gr"]
10
-
11
- spec.summary = "A friendly Ruby frontend for Borg backup"
12
- spec.description = "Ruborg provides a user-friendly interface to Borg backup. " \
13
- "It reads YAML configuration files and orchestrates backup operations, " \
14
- "supporting repository creation, backup management, and Passbolt integration."
15
- spec.homepage = "https://github.com/mpantel/ruborg"
16
- spec.license = "MIT"
17
- spec.required_ruby_version = ">= 3.2.0"
18
-
19
- spec.metadata["homepage_uri"] = spec.homepage
20
- spec.metadata["source_code_uri"] = "#{spec.homepage}.git"
21
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
- spec.metadata["rubygems_mfa_required"] = "true"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- spec.files = Dir.chdir(__dir__) do
26
- `git ls-files -z`.split("\x0").reject do |f|
27
- (File.expand_path(f) == __FILE__) ||
28
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
29
- end
30
- end
31
- spec.bindir = "exe"
32
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
- spec.require_paths = ["lib"]
34
-
35
- # Dependencies
36
- spec.add_dependency "psych", "~> 5.0"
37
- spec.add_dependency "thor", "~> 1.3"
38
-
39
- # Development dependencies
40
- spec.add_development_dependency "bundler", "~> 2.0"
41
- spec.add_development_dependency "bundler-audit", "~> 0.9"
42
- spec.add_development_dependency "rake", "~> 13.0"
43
- spec.add_development_dependency "rspec", "~> 3.0"
44
- spec.add_development_dependency "rubocop", "~> 1.0"
45
- spec.add_development_dependency "rubocop-rspec", "~> 3.0"
46
- end