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 +4 -4
 - data/CHANGELOG.md +87 -0
 - data/README.md +54 -5
 - data/SECURITY.md +26 -0
 - data/lib/ruborg/backup.rb +250 -9
 - data/lib/ruborg/cli.rb +81 -71
 - data/lib/ruborg/repository.rb +89 -0
 - data/lib/ruborg/version.rb +1 -1
 - metadata +1 -2
 - data/ruborg.gemspec +0 -46
 
    
        checksums.yaml
    CHANGED
    
    | 
         @@ -1,7 +1,7 @@ 
     | 
|
| 
       1 
1 
     | 
    
         
             
            ---
         
     | 
| 
       2 
2 
     | 
    
         
             
            SHA256:
         
     | 
| 
       3 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       4 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 3 
     | 
    
         
            +
              metadata.gz: 5b1659a54e64ed15742467c6e95ea96fd311f0332e2fafdee7e560cec5c2385c
         
     | 
| 
      
 4 
     | 
    
         
            +
              data.tar.gz: ec49de1e1231ad2bd189e08aedb70d22ec10f4f322f1b4690c9c3ec41be590bb
         
     | 
| 
       5 
5 
     | 
    
         
             
            SHA512:
         
     | 
| 
       6 
     | 
    
         
            -
              metadata.gz:  
     | 
| 
       7 
     | 
    
         
            -
              data.tar.gz:  
     | 
| 
      
 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 ( 
     | 
| 
      
 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  
     | 
| 
      
 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 
     | 
    
         
            -
            - ** 
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
      
 53 
     | 
    
         
            +
                  # Get list of existing archives for duplicate detection
         
     | 
| 
      
 54 
     | 
    
         
            +
                  existing_archives = get_existing_archive_names
         
     | 
| 
       42 
55 
     | 
    
         | 
| 
       43 
     | 
    
         
            -
                   
     | 
| 
      
 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 
     | 
    
         
            -
                     
     | 
| 
       49 
     | 
    
         
            -
             
     | 
| 
       50 
     | 
    
         
            -
             
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
      
 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  
     | 
| 
       105 
     | 
    
         
            -
                   
     | 
| 
      
 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, 
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       93 
     | 
    
         
            -
             
     | 
| 
      
 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 
     | 
| 
      
 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, 
     | 
| 
      
 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, 
     | 
| 
      
 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 
     | 
    
         
            -
                       
     | 
| 
      
 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 " 
     | 
| 
       250 
     | 
    
         
            -
                def  
     | 
| 
       251 
     | 
    
         
            -
                   
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       255 
     | 
    
         
            -
                  puts "  CONFIGURATION VALIDATION"
         
     | 
| 
       256 
     | 
    
         
            -
                  puts "═══════════════════════════════════════════════════════════════\n\n"
         
     | 
| 
      
 273 
     | 
    
         
            +
                  raise ConfigError, "Please specify --repository" unless options[:repository]
         
     | 
| 
       257 
274 
     | 
    
         | 
| 
       258 
     | 
    
         
            -
                   
     | 
| 
       259 
     | 
    
         
            -
                   
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       264 
     | 
    
         
            -
                   
     | 
| 
       265 
     | 
    
         
            -
                   
     | 
| 
      
 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 
     | 
    
         
            -
                   
     | 
| 
       268 
     | 
    
         
            -
             
     | 
| 
       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 
     | 
    
         
            -
                  #  
     | 
| 
       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 
     | 
    
         
            -
             
     | 
| 
       281 
     | 
    
         
            -
             
     | 
| 
       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  
     | 
| 
       287 
     | 
    
         
            -
                   
     | 
| 
       288 
     | 
    
         
            -
             
     | 
| 
       289 
     | 
    
         
            -
             
     | 
| 
       290 
     | 
    
         
            -
                   
     | 
| 
       291 
     | 
    
         
            -
             
     | 
| 
       292 
     | 
    
         
            -
             
     | 
| 
       293 
     | 
    
         
            -
             
     | 
| 
       294 
     | 
    
         
            -
             
     | 
| 
       295 
     | 
    
         
            -
             
     | 
| 
       296 
     | 
    
         
            -
             
     | 
| 
       297 
     | 
    
         
            -
             
     | 
| 
       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(" 
     | 
| 
      
 307 
     | 
    
         
            +
                  @logger.info("Successfully retrieved metadata for #{metadata["path"]}")
         
     | 
| 
       316 
308 
     | 
    
         
             
                rescue Error => e
         
     | 
| 
       317 
     | 
    
         
            -
                  @logger.error(" 
     | 
| 
       318 
     | 
    
         
            -
                   
     | 
| 
      
 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, 
     | 
| 
      
 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, 
     | 
| 
      
 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, 
     | 
| 
      
 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"}")
         
     | 
    
        data/lib/ruborg/repository.rb
    CHANGED
    
    | 
         @@ -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?
         
     | 
    
        data/lib/ruborg/version.rb
    CHANGED
    
    
    
        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. 
     | 
| 
      
 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
         
     |