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.
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
- detect_format
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 = ["lz4", "zstd", "zlib", "lzma", "none"].freeze
94
- VALID_ENCRYPTION = ["repokey", "keyfile", "none", "authenticated", "repokey-blake2",
95
- "keyfile-blake2", "authenticated-blake2"].freeze
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
- def detect_format
98
- @multi_repo = @data.key?("repositories")
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, progname, msg|
17
- "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
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
- dir = File.expand_path("~/.ruborg/logs")
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
@@ -24,14 +24,14 @@ module Ruborg
24
24
  private
25
25
 
26
26
  def check_passbolt_cli
27
- unless system("which passbolt > /dev/null 2>&1")
28
- raise PassboltError, "Passbolt CLI not found. Please install it first."
29
- end
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, stderr, status = Open3.capture3(*cmd)
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
@@ -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 = ["borg", "init", "--encryption=repokey", @path]
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 = ["borg", "info", @path]
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 = ["borg", "list", @path]
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(' ')}" unless result
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruborg
4
- VERSION = "0.3.1"
5
- end
4
+ VERSION = "0.4.0"
5
+ end
data/lib/ruborg.rb CHANGED
@@ -13,4 +13,4 @@ module Ruborg
13
13
  class ConfigError < Error; end
14
14
  class BorgError < Error; end
15
15
  class PassboltError < Error; end
16
- end
16
+ end