ruborg 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +175 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +67 -1
- data/README.md +337 -80
- data/Rakefile +1 -1
- data/SECURITY.md +41 -2
- data/exe/ruborg +1 -1
- data/lib/ruborg/backup.rb +97 -12
- data/lib/ruborg/cli.rb +257 -78
- data/lib/ruborg/config.rb +18 -61
- data/lib/ruborg/logger.rb +4 -5
- data/lib/ruborg/passbolt.rb +5 -5
- data/lib/ruborg/repository.rb +275 -7
- data/lib/ruborg/version.rb +2 -2
- data/lib/ruborg.rb +1 -1
- data/ruborg.yml.example +136 -22
- metadata +41 -12
data/lib/ruborg/config.rb
CHANGED
|
@@ -5,13 +5,14 @@ require "psych"
|
|
|
5
5
|
|
|
6
6
|
module Ruborg
|
|
7
7
|
# Configuration management for ruborg
|
|
8
|
+
# Only supports multi-repository format
|
|
8
9
|
class Config
|
|
9
10
|
attr_reader :data
|
|
10
11
|
|
|
11
12
|
def initialize(config_path)
|
|
12
13
|
@config_path = config_path
|
|
13
14
|
load_config
|
|
14
|
-
|
|
15
|
+
validate_format
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def load_config
|
|
@@ -24,91 +25,49 @@ module Ruborg
|
|
|
24
25
|
raise ConfigError, "Invalid YAML content: #{e.message}"
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
# Legacy single-repo accessors (for backward compatibility)
|
|
28
|
-
def repository
|
|
29
|
-
@data["repository"]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def backup_paths
|
|
33
|
-
@data["backup_paths"] || []
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def exclude_patterns
|
|
37
|
-
patterns = @data["exclude_patterns"] || []
|
|
38
|
-
validate_exclude_patterns(patterns)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def compression
|
|
42
|
-
value = @data["compression"] || "lz4"
|
|
43
|
-
validate_compression(value)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def encryption_mode
|
|
47
|
-
value = @data["encryption"] || "repokey"
|
|
48
|
-
validate_encryption(value)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def passbolt_integration
|
|
52
|
-
@data["passbolt"] || {}
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def auto_init?
|
|
56
|
-
@data["auto_init"] || false
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def log_file
|
|
60
|
-
@data["log_file"]
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def borg_options
|
|
64
|
-
@data["borg_options"] || {}
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# New multi-repo support
|
|
68
|
-
def multi_repo?
|
|
69
|
-
@multi_repo
|
|
70
|
-
end
|
|
71
|
-
|
|
72
28
|
def repositories
|
|
73
|
-
return [] unless multi_repo?
|
|
74
29
|
@data["repositories"] || []
|
|
75
30
|
end
|
|
76
31
|
|
|
77
32
|
def get_repository(name)
|
|
78
|
-
return nil unless multi_repo?
|
|
79
33
|
repositories.find { |r| r["name"] == name }
|
|
80
34
|
end
|
|
81
35
|
|
|
82
36
|
def repository_names
|
|
83
|
-
return [] unless multi_repo?
|
|
84
37
|
repositories.map { |r| r["name"] }
|
|
85
38
|
end
|
|
86
39
|
|
|
87
40
|
def global_settings
|
|
88
|
-
@data.slice("passbolt", "compression", "encryption", "auto_init"
|
|
41
|
+
@data.slice("passbolt", "compression", "encryption", "auto_init", "borg_options", "log_file", "retention",
|
|
42
|
+
"auto_prune")
|
|
89
43
|
end
|
|
90
44
|
|
|
91
45
|
private
|
|
92
46
|
|
|
93
|
-
VALID_COMPRESSION = [
|
|
94
|
-
VALID_ENCRYPTION = [
|
|
95
|
-
|
|
47
|
+
VALID_COMPRESSION = %w[lz4 zstd zlib lzma none].freeze
|
|
48
|
+
VALID_ENCRYPTION = %w[repokey keyfile none authenticated repokey-blake2
|
|
49
|
+
keyfile-blake2 authenticated-blake2].freeze
|
|
50
|
+
|
|
51
|
+
def validate_format
|
|
52
|
+
return if @data.key?("repositories")
|
|
96
53
|
|
|
97
|
-
|
|
98
|
-
|
|
54
|
+
raise ConfigError,
|
|
55
|
+
"Invalid configuration format. Multi-repository format required. See documentation for details."
|
|
99
56
|
end
|
|
100
57
|
|
|
101
58
|
def validate_compression(compression)
|
|
102
59
|
unless VALID_COMPRESSION.include?(compression)
|
|
103
|
-
raise ConfigError, "Invalid compression '#{compression}'. Must be one of: #{VALID_COMPRESSION.join(
|
|
60
|
+
raise ConfigError, "Invalid compression '#{compression}'. Must be one of: #{VALID_COMPRESSION.join(", ")}"
|
|
104
61
|
end
|
|
62
|
+
|
|
105
63
|
compression
|
|
106
64
|
end
|
|
107
65
|
|
|
108
66
|
def validate_encryption(encryption)
|
|
109
67
|
unless VALID_ENCRYPTION.include?(encryption)
|
|
110
|
-
raise ConfigError, "Invalid encryption mode '#{encryption}'. Must be one of: #{VALID_ENCRYPTION.join(
|
|
68
|
+
raise ConfigError, "Invalid encryption mode '#{encryption}'. Must be one of: #{VALID_ENCRYPTION.join(", ")}"
|
|
111
69
|
end
|
|
70
|
+
|
|
112
71
|
encryption
|
|
113
72
|
end
|
|
114
73
|
|
|
@@ -116,9 +75,7 @@ module Ruborg
|
|
|
116
75
|
return patterns if patterns.empty?
|
|
117
76
|
|
|
118
77
|
patterns.each do |pattern|
|
|
119
|
-
if pattern.nil? || pattern.to_s.strip.empty?
|
|
120
|
-
raise ConfigError, "Exclude pattern cannot be empty or nil"
|
|
121
|
-
end
|
|
78
|
+
raise ConfigError, "Exclude pattern cannot be empty or nil" if pattern.nil? || pattern.to_s.strip.empty?
|
|
122
79
|
|
|
123
80
|
if pattern.length > 1000
|
|
124
81
|
raise ConfigError, "Exclude pattern too long (max 1000 characters): #{pattern[0..50]}..."
|
|
@@ -128,4 +85,4 @@ module Ruborg
|
|
|
128
85
|
patterns
|
|
129
86
|
end
|
|
130
87
|
end
|
|
131
|
-
end
|
|
88
|
+
end
|
data/lib/ruborg/logger.rb
CHANGED
|
@@ -13,8 +13,8 @@ module Ruborg
|
|
|
13
13
|
validate_and_ensure_log_directory
|
|
14
14
|
@logger = Logger.new(@log_file, "daily")
|
|
15
15
|
@logger.level = Logger::INFO
|
|
16
|
-
@logger.formatter = proc do |severity, datetime,
|
|
17
|
-
"[#{datetime.strftime(
|
|
16
|
+
@logger.formatter = proc do |severity, datetime, _progname, msg|
|
|
17
|
+
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg}\n"
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -41,8 +41,7 @@ module Ruborg
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def log_directory
|
|
44
|
-
|
|
45
|
-
dir
|
|
44
|
+
File.expand_path("~/.ruborg/logs")
|
|
46
45
|
end
|
|
47
46
|
|
|
48
47
|
def validate_and_ensure_log_directory
|
|
@@ -62,4 +61,4 @@ module Ruborg
|
|
|
62
61
|
FileUtils.mkdir_p(log_dir) unless File.directory?(log_dir)
|
|
63
62
|
end
|
|
64
63
|
end
|
|
65
|
-
end
|
|
64
|
+
end
|
data/lib/ruborg/passbolt.rb
CHANGED
|
@@ -24,14 +24,14 @@ module Ruborg
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def check_passbolt_cli
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
return if system("which passbolt > /dev/null 2>&1")
|
|
28
|
+
|
|
29
|
+
raise PassboltError, "Passbolt CLI not found. Please install it first."
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def execute_command(cmd)
|
|
33
33
|
require "open3"
|
|
34
|
-
stdout,
|
|
34
|
+
stdout, _, status = Open3.capture3(*cmd)
|
|
35
35
|
[stdout, status.success?]
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -42,4 +42,4 @@ module Ruborg
|
|
|
42
42
|
raise PassboltError, "Failed to parse Passbolt response: #{e.message}"
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
|
-
end
|
|
45
|
+
end
|
data/lib/ruborg/repository.rb
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "English"
|
|
3
4
|
module Ruborg
|
|
4
5
|
# Borg repository management
|
|
5
6
|
class Repository
|
|
6
|
-
attr_reader :path
|
|
7
|
+
attr_reader :path, :borg_path
|
|
7
8
|
|
|
8
|
-
def initialize(path, passphrase: nil, borg_options: {})
|
|
9
|
+
def initialize(path, passphrase: nil, borg_options: {}, borg_path: nil)
|
|
9
10
|
@path = validate_repo_path(path)
|
|
10
11
|
@passphrase = passphrase
|
|
11
12
|
@borg_options = borg_options
|
|
13
|
+
@borg_path = validate_borg_path(borg_path || "borg")
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def exists?
|
|
@@ -18,26 +20,253 @@ module Ruborg
|
|
|
18
20
|
def create
|
|
19
21
|
raise BorgError, "Repository already exists at #{@path}" if exists?
|
|
20
22
|
|
|
21
|
-
cmd = [
|
|
23
|
+
cmd = [@borg_path, "init", "--encryption=repokey", @path]
|
|
22
24
|
execute_borg_command(cmd)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def info
|
|
26
28
|
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
27
29
|
|
|
28
|
-
cmd = [
|
|
30
|
+
cmd = [@borg_path, "info", @path]
|
|
29
31
|
execute_borg_command(cmd)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def list
|
|
33
35
|
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
34
36
|
|
|
35
|
-
cmd = [
|
|
37
|
+
cmd = [@borg_path, "list", @path]
|
|
36
38
|
execute_borg_command(cmd)
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
def prune(retention_policy = {}, retention_mode: "standard")
|
|
42
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
43
|
+
raise BorgError, "No retention policy specified" if retention_policy.nil? || retention_policy.empty?
|
|
44
|
+
|
|
45
|
+
if retention_mode == "per_file"
|
|
46
|
+
prune_per_file_archives(retention_policy)
|
|
47
|
+
else
|
|
48
|
+
prune_standard_archives(retention_policy)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def prune_standard_archives(retention_policy)
|
|
55
|
+
cmd = [@borg_path, "prune", @path, "--stats"]
|
|
56
|
+
|
|
57
|
+
# Add count-based retention options
|
|
58
|
+
cmd += ["--keep-hourly", retention_policy["keep_hourly"].to_s] if retention_policy["keep_hourly"]
|
|
59
|
+
cmd += ["--keep-daily", retention_policy["keep_daily"].to_s] if retention_policy["keep_daily"]
|
|
60
|
+
cmd += ["--keep-weekly", retention_policy["keep_weekly"].to_s] if retention_policy["keep_weekly"]
|
|
61
|
+
cmd += ["--keep-monthly", retention_policy["keep_monthly"].to_s] if retention_policy["keep_monthly"]
|
|
62
|
+
cmd += ["--keep-yearly", retention_policy["keep_yearly"].to_s] if retention_policy["keep_yearly"]
|
|
63
|
+
|
|
64
|
+
# Add time-based retention options
|
|
65
|
+
cmd += ["--keep-within", retention_policy["keep_within"]] if retention_policy["keep_within"]
|
|
66
|
+
cmd += ["--keep-last", retention_policy["keep_last"]] if retention_policy["keep_last"]
|
|
67
|
+
|
|
68
|
+
execute_borg_command(cmd)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def prune_per_file_archives(retention_policy)
|
|
72
|
+
# Get file metadata-based retention setting
|
|
73
|
+
keep_files_modified_within = retention_policy["keep_files_modified_within"]
|
|
74
|
+
|
|
75
|
+
unless keep_files_modified_within
|
|
76
|
+
# Fall back to standard pruning if no file metadata retention specified
|
|
77
|
+
prune_standard_archives(retention_policy)
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse time duration (e.g., "30d" -> 30 days)
|
|
82
|
+
cutoff_time = Time.now - parse_time_duration(keep_files_modified_within)
|
|
83
|
+
|
|
84
|
+
# Get all archives with metadata
|
|
85
|
+
archives = list_archives_with_metadata
|
|
86
|
+
|
|
87
|
+
archives_to_delete = []
|
|
88
|
+
|
|
89
|
+
archives.each do |archive|
|
|
90
|
+
# Get file metadata from archive
|
|
91
|
+
file_mtime = get_file_mtime_from_archive(archive[:name])
|
|
92
|
+
|
|
93
|
+
# Delete archive if file was modified before cutoff
|
|
94
|
+
archives_to_delete << archive[:name] if file_mtime && file_mtime < cutoff_time
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Delete archives
|
|
98
|
+
archives_to_delete.each do |archive_name|
|
|
99
|
+
delete_archive(archive_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return if archives_to_delete.empty?
|
|
103
|
+
|
|
104
|
+
puts "Pruned #{archives_to_delete.size} archive(s) based on file modification time"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def list_archives_with_metadata
|
|
108
|
+
require "json"
|
|
109
|
+
require "time"
|
|
110
|
+
require "open3"
|
|
111
|
+
|
|
112
|
+
cmd = [@borg_path, "list", @path, "--json"]
|
|
113
|
+
env = build_borg_env
|
|
114
|
+
|
|
115
|
+
# Use Open3.capture3 for safe command execution with environment variables
|
|
116
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
117
|
+
|
|
118
|
+
raise BorgError, "Failed to list archives: #{stderr}" unless status.success?
|
|
119
|
+
|
|
120
|
+
json_data = JSON.parse(stdout)
|
|
121
|
+
archives = json_data["archives"] || []
|
|
122
|
+
|
|
123
|
+
archives.map do |archive|
|
|
124
|
+
{
|
|
125
|
+
name: archive["name"],
|
|
126
|
+
time: Time.parse(archive["time"])
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
rescue JSON::ParserError => e
|
|
130
|
+
raise BorgError, "Failed to parse archive list: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get_file_mtime_from_archive(archive_name)
|
|
134
|
+
require "json"
|
|
135
|
+
require "time"
|
|
136
|
+
require "open3"
|
|
137
|
+
|
|
138
|
+
cmd = [@borg_path, "list", "#{@path}::#{archive_name}", "--json-lines"]
|
|
139
|
+
env = build_borg_env
|
|
140
|
+
|
|
141
|
+
# Use Open3.capture3 for safe command execution with environment variables
|
|
142
|
+
stdout, _stderr, status = Open3.capture3(env, *cmd)
|
|
143
|
+
|
|
144
|
+
unless status.success?
|
|
145
|
+
return nil # Archive might be corrupted or inaccessible
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Parse first line (should be the only file in per-file archives)
|
|
149
|
+
first_line = stdout.lines.first
|
|
150
|
+
return nil unless first_line
|
|
151
|
+
|
|
152
|
+
file_data = JSON.parse(first_line)
|
|
153
|
+
mtime_str = file_data["mtime"]
|
|
154
|
+
|
|
155
|
+
return nil unless mtime_str
|
|
156
|
+
|
|
157
|
+
# Parse mtime (format: "2025-10-06T12:34:56.123456")
|
|
158
|
+
Time.parse(mtime_str)
|
|
159
|
+
rescue JSON::ParserError, ArgumentError
|
|
160
|
+
nil # Failed to parse, skip this archive
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def delete_archive(archive_name)
|
|
164
|
+
cmd = [@borg_path, "delete", "#{@path}::#{archive_name}"]
|
|
165
|
+
execute_borg_command(cmd)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_time_duration(duration_str)
|
|
169
|
+
# Parse duration strings like "30d", "7w", "6m", "1y"
|
|
170
|
+
match = duration_str.match(/^(\d+)([dwmy])$/)
|
|
171
|
+
raise BorgError, "Invalid time duration format: #{duration_str}" unless match
|
|
172
|
+
|
|
173
|
+
value = match[1].to_i
|
|
174
|
+
unit = match[2]
|
|
175
|
+
|
|
176
|
+
case unit
|
|
177
|
+
when "d"
|
|
178
|
+
value * 24 * 60 * 60 # days to seconds
|
|
179
|
+
when "w"
|
|
180
|
+
value * 7 * 24 * 60 * 60 # weeks to seconds
|
|
181
|
+
when "m"
|
|
182
|
+
value * 30 * 24 * 60 * 60 # months (approx) to seconds
|
|
183
|
+
when "y"
|
|
184
|
+
value * 365 * 24 * 60 * 60 # years (approx) to seconds
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_borg_env
|
|
189
|
+
env = {}
|
|
190
|
+
env["BORG_PASSPHRASE"] = @passphrase if @passphrase
|
|
191
|
+
|
|
192
|
+
allow_relocated = @borg_options.fetch("allow_relocated_repo", true)
|
|
193
|
+
allow_unencrypted = @borg_options.fetch("allow_unencrypted_repo", true)
|
|
194
|
+
|
|
195
|
+
env["BORG_RELOCATED_REPO_ACCESS_IS_OK"] = allow_relocated ? "yes" : "no"
|
|
196
|
+
env["BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK"] = allow_unencrypted ? "yes" : "no"
|
|
197
|
+
|
|
198
|
+
env
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
public
|
|
202
|
+
|
|
203
|
+
def check
|
|
204
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
205
|
+
|
|
206
|
+
cmd = [@borg_path, "check", @path]
|
|
207
|
+
execute_borg_command(cmd)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Get Borg version
|
|
211
|
+
def self.borg_version(borg_path = "borg")
|
|
212
|
+
output, status = execute_version_command(borg_path)
|
|
213
|
+
raise BorgError, "Borg is not installed or not in PATH" unless status.success?
|
|
214
|
+
|
|
215
|
+
# Parse version from output like "borg 1.2.8"
|
|
216
|
+
match = output.match(/borg (\d+\.\d+\.\d+)/)
|
|
217
|
+
raise BorgError, "Could not parse Borg version from: #{output}" unless match
|
|
218
|
+
|
|
219
|
+
match[1]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Execute borg version command (extracted for testing)
|
|
223
|
+
def self.execute_version_command(borg_path = "borg")
|
|
224
|
+
require "open3"
|
|
225
|
+
|
|
226
|
+
# Use Open3.capture2e for safe command execution
|
|
227
|
+
output, status = Open3.capture2e(borg_path, "--version")
|
|
228
|
+
[output.strip, status]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Check compatibility between Borg version and repository
|
|
232
|
+
def check_compatibility
|
|
233
|
+
raise BorgError, "Repository does not exist at #{@path}" unless exists?
|
|
234
|
+
|
|
235
|
+
borg_version = self.class.borg_version(@borg_path)
|
|
236
|
+
borg_major, borg_minor = borg_version.split(".").map(&:to_i)
|
|
237
|
+
|
|
238
|
+
# Get repository version from config
|
|
239
|
+
config_file = File.join(@path, "config")
|
|
240
|
+
config_content = File.read(config_file)
|
|
241
|
+
|
|
242
|
+
# Extract version from config (format: version = 1)
|
|
243
|
+
repo_version = config_content.match(/version\s*=\s*(\d+)/)&.captures&.first&.to_i
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
borg_version: borg_version,
|
|
247
|
+
repository_version: repo_version,
|
|
248
|
+
compatible: check_version_compatibility(borg_major, borg_minor, repo_version)
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
|
|
39
252
|
private
|
|
40
253
|
|
|
254
|
+
def check_version_compatibility(borg_major, _borg_minor, repo_version)
|
|
255
|
+
# Borg 1.x can ONLY work with repository version 1
|
|
256
|
+
return true if borg_major == 1 && repo_version == 1
|
|
257
|
+
return false if borg_major == 1 && repo_version == 2
|
|
258
|
+
|
|
259
|
+
# Borg 2.x can work with repository version 2
|
|
260
|
+
# Borg 2.x can READ from version 1 repos (read-only, for migration)
|
|
261
|
+
# but for regular operations, version 2 repos are required
|
|
262
|
+
return true if borg_major == 2 && repo_version == 2
|
|
263
|
+
# Version 1 repos with Borg 2.x are limited (read-only for migration)
|
|
264
|
+
return false if borg_major == 2 && repo_version == 1
|
|
265
|
+
|
|
266
|
+
# Unknown combinations - assume incompatible
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
|
|
41
270
|
def validate_repo_path(path)
|
|
42
271
|
raise BorgError, "Repository path cannot be empty" if path.nil? || path.empty?
|
|
43
272
|
|
|
@@ -54,6 +283,45 @@ module Ruborg
|
|
|
54
283
|
normalized
|
|
55
284
|
end
|
|
56
285
|
|
|
286
|
+
def validate_borg_path(borg_path)
|
|
287
|
+
raise BorgError, "Borg path cannot be empty" if borg_path.nil? || borg_path.to_s.strip.empty?
|
|
288
|
+
|
|
289
|
+
# Check if the command is executable
|
|
290
|
+
# For commands in PATH (like "borg"), use which to find the full path
|
|
291
|
+
full_path = if borg_path.include?("/")
|
|
292
|
+
# Absolute or relative path provided
|
|
293
|
+
File.expand_path(borg_path)
|
|
294
|
+
else
|
|
295
|
+
# Command name only - search in PATH
|
|
296
|
+
find_in_path(borg_path)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Verify the executable exists and is actually executable
|
|
300
|
+
unless full_path && File.executable?(full_path)
|
|
301
|
+
raise BorgError, "Borg executable not found or not executable: #{borg_path}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Verify it's actually borg by checking version output
|
|
305
|
+
begin
|
|
306
|
+
output, status = self.class.execute_version_command(borg_path)
|
|
307
|
+
unless status.success? && output.match?(/borg \d+\.\d+/)
|
|
308
|
+
raise BorgError, "Command '#{borg_path}' does not appear to be Borg backup"
|
|
309
|
+
end
|
|
310
|
+
rescue StandardError => e
|
|
311
|
+
raise BorgError, "Failed to verify Borg executable: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
borg_path
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def find_in_path(command)
|
|
318
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).each do |directory|
|
|
319
|
+
path = File.join(directory, command)
|
|
320
|
+
return path if File.executable?(path)
|
|
321
|
+
end
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
57
325
|
def execute_borg_command(cmd)
|
|
58
326
|
env = {}
|
|
59
327
|
env["BORG_PASSPHRASE"] = @passphrase if @passphrase
|
|
@@ -67,9 +335,9 @@ module Ruborg
|
|
|
67
335
|
|
|
68
336
|
# Redirect stdin from /dev/null to prevent interactive prompts
|
|
69
337
|
result = system(env, *cmd, in: "/dev/null")
|
|
70
|
-
raise BorgError, "Borg command failed: #{cmd.join(
|
|
338
|
+
raise BorgError, "Borg command failed: #{cmd.join(" ")}" unless result
|
|
71
339
|
|
|
72
340
|
result
|
|
73
341
|
end
|
|
74
342
|
end
|
|
75
|
-
end
|
|
343
|
+
end
|
data/lib/ruborg/version.rb
CHANGED
data/lib/ruborg.rb
CHANGED