mclone 0.1.1 → 0.2.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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +8 -4
  3. data/README.md +409 -369
  4. data/bin/mclone +23 -8
  5. data/lib/mclone.rb +223 -126
  6. metadata +3 -3
data/bin/mclone CHANGED
@@ -13,7 +13,9 @@ Clamp do
13
13
 
14
14
  using Refinements
15
15
 
16
- option ['-f', '--force'], :flag, 'Insist on potentially dangerous activities', default: false
16
+ self.default_subcommand = 'info'
17
+
18
+ option ['-f', '--force'], :flag, 'Insist on potentially dangerous actions', default: false
17
19
  option ['-n', '--dry-run'], :flag, 'Simulation mode with no on-disk modifications', default: false
18
20
  option ['-v', '--verbose'], :flag, 'Verbose operation', default: false
19
21
 
@@ -46,9 +48,7 @@ Clamp do
46
48
  $stdout.puts
47
49
  $stdout.puts '## Volumes'
48
50
  $stdout.puts
49
- s.volumes.each do |volume|
50
- $stdout.puts "* [#{volume.id}] :: (#{volume.root})"
51
- end
51
+ s.volumes.each { |volume| $stdout.puts "* [#{volume.id}] :: (#{volume.root})" }
52
52
  stales = []
53
53
  intacts = []
54
54
  intact_tasks = s.intact_tasks
@@ -56,7 +56,8 @@ Clamp do
56
56
  ts = (t = intact_tasks[task]).nil? ? "<#{task.id}>" : "[#{task.id}]"
57
57
  svs = s.volumes.volume(task.source_id).nil? ? "<#{task.source_id}>" : "[#{task.source_id}]"
58
58
  dvs = s.volumes.volume(task.destination_id).nil? ? "<#{task.destination_id}>" : "[#{task.destination_id}]"
59
- xs = ["* #{ts} :: #{task.mode} #{svs}(#{task.source_root}) -> #{dvs}(#{task.destination_root})"]
59
+ crypter_mode = task.crypter_mode.nil? ? nil : "#{task.crypter_mode}+"
60
+ xs = ["* #{ts} :: #{crypter_mode}#{task.mode} #{svs}(#{task.source_root}) -> #{dvs}(#{task.destination_root})"]
60
61
  xs << "include #{task.include}" unless task.include.nil? || task.include.empty?
61
62
  xs << "exclude #{task.exclude}" unless task.exclude.nil? || task.exclude.empty?
62
63
  (t.nil? ? stales : intacts) << xs.join(' :: ')
@@ -94,21 +95,35 @@ Clamp do
94
95
 
95
96
  end
96
97
 
97
- subcommand ['task'], 'Task operations' do
98
+ subcommand 'task', 'Task operations' do
98
99
 
99
100
  def self.set_task_opts
100
101
  modes = Task::MODES.collect(&:to_s).join(' | ')
101
102
  option ['-m', '--mode'], 'MODE', "Operation mode (#{modes})", default: Task::MODES.first.to_s
102
- option ['-i', '--include'], 'PATTERN', 'Include paths pattern', default: '**'
103
+ option ['-i', '--include'], 'PATTERN', 'Include paths pattern'
103
104
  option ['-x', '--exclude'], 'PATTERN', 'Exclude paths pattern'
104
105
  end
105
106
 
106
107
  subcommand ['new', 'create'], 'Create new SOURCE -> DESTINATION task' do
107
108
  set_task_opts
109
+ option ['-d', '--decrypt'], :flag, 'Decrypt source'
110
+ option ['-e', '--encrypt'], :flag, 'Encrypt destination'
111
+ option ['-p', '--password'], 'PASSWORD', 'Plain text password'
112
+ option ['-t', '--token'], 'TOKEN', 'Rclone crypt token (obscured password)'
108
113
  parameter 'SOURCE', 'Source path'
109
114
  parameter 'DESTINATION', 'Destination path'
110
115
  def execute
111
- session.create_task!(source, destination, mode: resolve_mode(mode), include: include, exclude: exclude).commit!
116
+ crypter_mode = nil
117
+ signal_usage_error 'choose either encryption or decryption mode, not both' if decrypt? && encrypt?
118
+ signal_usage_error 'specify either plain text password or Rclone crypt token, not both' if !password.nil? && !token.nil?
119
+ crypter_mode = :encrypt if encrypt?
120
+ crypter_mode = :decrypt if decrypt?
121
+ session.create_task!(
122
+ resolve_mode(mode),
123
+ source,
124
+ destination,
125
+ include: include, exclude: exclude, crypter_mode: crypter_mode, crypter_password: password, crypter_token: token
126
+ ).commit!
112
127
  end
113
128
  end
114
129
 
data/lib/mclone.rb CHANGED
@@ -3,7 +3,9 @@
3
3
 
4
4
  require 'date'
5
5
  require 'json'
6
+ require 'open3'
6
7
  require 'fileutils'
8
+ require 'shellwords'
7
9
  require 'securerandom'
8
10
 
9
11
 
@@ -11,13 +13,11 @@ require 'securerandom'
11
13
  module Mclone
12
14
 
13
15
 
14
- VERSION = '0.1.1'
16
+ VERSION = '0.2.0'
15
17
 
16
18
 
17
19
  #
18
- class Error < StandardError
19
-
20
- end
20
+ class Error < StandardError; end
21
21
 
22
22
  #
23
23
  module Refinements
@@ -41,6 +41,11 @@ module Mclone
41
41
  end
42
42
  end
43
43
 
44
+ refine ::String do
45
+ def escape
46
+ Mclone.windows? && %r![^\w\-\=\\\/:]!.match?(self) ? %("#{self}") : shellescape
47
+ end
48
+ end
44
49
  end
45
50
 
46
51
 
@@ -67,12 +72,31 @@ module Mclone
67
72
  @objects.empty?
68
73
  end
69
74
 
75
+ #
76
+ def size
77
+ @objects.size
78
+ end
79
+
70
80
  def initialize
71
81
  @ids = {} # { id => object }
72
82
  @objects = {} # { object => object }
73
83
  @modified = false
74
84
  end
75
85
 
86
+ protected attr_reader :objects
87
+
88
+ #
89
+ def hash
90
+ @objects.hash
91
+ end
92
+
93
+ #
94
+ def eql?(other)
95
+ equal?(other) || objects == other.objects
96
+ end
97
+
98
+ alias == eql?
99
+
76
100
  # Return ID of the object considered equal to the specified obj or nil
77
101
  def id(obj)
78
102
  @objects[obj]&.id
@@ -156,6 +180,9 @@ module Mclone
156
180
  #
157
181
  attr_reader :include, :exclude
158
182
 
183
+ #
184
+ attr_reader :crypter_mode
185
+
159
186
  def hash
160
187
  @hash ||= source_id.hash ^ destination_id.hash ^ source_root.hash ^ destination_root.hash
161
188
  end
@@ -172,85 +199,139 @@ module Mclone
172
199
  alias == eql?
173
200
 
174
201
  #
175
- def initialize(source_id, source_root, destination_id, destination_root)
202
+ def initialize(session, mode, source_id, source_root, destination_id, destination_root, include: nil, exclude: nil, crypter_mode: nil, crypter_token: nil, crypter_password: nil)
176
203
  @touch = false # Indicates that the time stamp should be updated whenever state of self is altered
204
+ @session = session
177
205
  @id = SecureRandom.hex(4)
178
206
  @source_id = source_id
179
207
  @destination_id = destination_id
180
208
  @source_root = source_root
181
209
  @destination_root = destination_root
182
- self.mode = :update
183
- self.include = '**'
184
- self.exclude = ''
210
+ self.mode = mode
211
+ self.include = include
212
+ self.exclude = exclude
213
+ set_crypter_mode crypter_mode
214
+ unless crypter_mode.nil?
215
+ raise(Task::Error, %(either Rclone crypt token or plain text password is expected, not both)) if !crypter_token.nil? && !crypter_password.nil?
216
+ @assigned_token = register_crypter_token crypter_token
217
+ @assigned_password = crypter_password
218
+ end
185
219
  ensure
186
220
  @touch = true
187
221
  touch!
188
222
  end
189
223
 
224
+ CRYPTER_MODES = %i[encrypt decrypt].freeze
225
+
226
+ @@crypter_tokens = {}
227
+
228
+ private def set_crypter_mode(mode)
229
+ @crypter_mode = mode.nil? ? nil : (CRYPTER_MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown crypt mode "#{mode}")))
230
+ end
231
+
232
+ private def register_crypter_token(token)
233
+ unless token.nil?
234
+ @@crypter_tokens[id] = @@crypter_tokens[id].nil? ? token : raise(Task::Error, %(attempt to re-register token for task "#{id}"))
235
+ end
236
+ token
237
+ end
238
+
239
+ # Lazily determine the crypt token from either assigned values or the token repository
240
+ def crypter_token
241
+ # Locally assigned token takes precedence over the repository's
242
+ unless @assigned_token.nil?
243
+ @@crypter_tokens[id] = @assigned_token unless @@crypter_tokens[id].nil? # Assign repository entry with this local token if not yet assigned
244
+ @assigned_token
245
+ else
246
+ if @@crypter_tokens[id].nil?
247
+ # If token is neither locally assigned nor in repository, try to construct it from the user-supplied password
248
+ # If a user-supplied password is omitted, create a new randomly generated password
249
+ args = [Mclone.rclone, 'obscure', @assigned_password.nil? ? SecureRandom.alphanumeric(16) : @assigned_password]
250
+ $stdout << args.collect(&:escape).join(' ') << "\n" if @session.verbose?
251
+ stdout, status = Open3.capture2(*args)
252
+ raise(Task::Error, %(Rclone execution failure)) unless status.success?
253
+ @@crypter_tokens[id] = stdout.strip
254
+ else
255
+ @@crypter_tokens[id]
256
+ end
257
+ end
258
+ end
259
+
190
260
  #
191
- MODES = [:update, :synchronize, :copy, :move].freeze
261
+ MODES = %i[update synchronize copy move].freeze
192
262
 
193
263
  #
194
264
  def mode=(mode)
195
- unless mode.nil?
196
- @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
197
- touch!
198
- end
265
+ @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
266
+ touch!
267
+ mode
199
268
  end
200
269
 
201
270
  #
202
271
  def include=(mask)
203
- unless mask.nil?
204
- @include = mask # TODO extensive verification
205
- touch!
206
- end
272
+ @include = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
273
+ touch!
274
+ mask
207
275
  end
208
276
 
209
277
  #
210
278
  def exclude=(mask)
211
- unless mask.nil?
212
- @exclude = mask # TODO extensive verification
213
- touch!
214
- end
279
+ @exclude = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
280
+ touch!
281
+ mask
215
282
  end
216
283
 
217
284
  #
218
- def self.restore(hash)
285
+ def self.restore(session, hash)
219
286
  obj = allocate
220
- obj.send(:from_h, hash)
287
+ obj.send(:from_h, session, hash)
221
288
  obj
222
289
  end
223
290
 
224
291
  #
225
- private def from_h(hash)
226
- initialize(hash.extract(:source, :volume), hash.extract(:source, :root), hash.extract(:destination, :volume), hash.extract(:destination, :root))
292
+ private def from_h(session, hash)
293
+ @session = session
227
294
  @touch = false
228
295
  @id = hash.extract(:task)
296
+ @mtime = DateTime.parse(hash.extract(:mtime)) rescue DateTime.now # Deleting mtime entry from json can be used to modify data out of mclone
297
+ @source_id = hash.extract(:source, :volume)
298
+ @destination_id = hash.extract(:destination, :volume)
299
+ @source_root = hash.dig(:source, :root)
300
+ @destination_root = hash.dig(:destination, :root)
229
301
  self.mode = hash.extract(:mode)
230
- # Deleting the mtime key from json
231
- @mtime = DateTime.parse(hash.extract(:mtime)) rescue @mtime
232
- self.include = hash.extract(:include) rescue nil
233
- self.exclude = hash.extract(:exclude) rescue nil
302
+ self.include = hash.dig(:include)
303
+ self.exclude = hash.dig(:exclude)
304
+ set_crypter_mode hash.dig(:crypter, :mode)
305
+ @assigned_token = register_crypter_token(hash.dig(:crypter, :token)) unless crypter_mode.nil?
234
306
  ensure
235
307
  @touch = true
236
308
  end
237
309
 
238
310
  #
239
- def to_h
240
- {
241
- mode: mode,
242
- include: include,
243
- exclude: exclude,
311
+ def to_h(volume)
312
+ hash = {
244
313
  task: id,
245
- source: {volume: source_id, root: source_root},
246
- destination: {volume: destination_id, root: destination_root},
247
- mtime: mtime
314
+ mode: mode,
315
+ mtime: mtime,
316
+ source: { volume: source_id },
317
+ destination: { volume: destination_id }
248
318
  }
319
+ hash[:source][:root] = source_root unless source_root.nil? || source_root.empty?
320
+ hash[:destination][:root] = destination_root unless destination_root.nil? || destination_root.empty?
321
+ hash[:include] = include unless include.nil?
322
+ hash[:exclude] = exclude unless exclude.nil?
323
+ unless crypter_mode.nil?
324
+ crypter = hash[:crypter] = { mode: crypter_mode }
325
+ # Make sure the token won't get into the encrypted volume's task
326
+ crypter[:token] = crypter_token if (crypter_mode == :encrypt && source_id == volume.id) || (crypter_mode == :decrypt && destination_id == volume.id)
327
+ end
328
+ hash
249
329
  end
250
330
 
251
331
  #
252
332
  def touch!
253
333
  @mtime = DateTime.now if @touch
334
+ self
254
335
  end
255
336
  end
256
337
 
@@ -270,12 +351,9 @@ module Mclone
270
351
  #
271
352
  def resolve(id)
272
353
  case (ids = super).size
273
- when 0
274
- raise(Task::Error, %(no task matching "#{id}" pattern found))
275
- when 1
276
- ids.first
277
- else
278
- raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
354
+ when 0 then raise(Task::Error, %(no task matching "#{id}" pattern found))
355
+ when 1 then ids.first
356
+ else raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
279
357
  end
280
358
  end
281
359
 
@@ -302,8 +380,6 @@ module Mclone
302
380
  #
303
381
  attr_reader :file
304
382
 
305
- #
306
- attr_reader :tasks
307
383
 
308
384
  #
309
385
  def root
@@ -311,31 +387,30 @@ module Mclone
311
387
  end
312
388
 
313
389
  #
314
- def initialize(file)
315
- @file = file
390
+ def initialize(session, file)
391
+ @loaded_tasks = ObjectSet.new
316
392
  @id = SecureRandom.hex(4)
317
- @tasks = VolumeTaskSet.new(self)
393
+ @session = session
394
+ @file = file
318
395
  end
319
396
 
320
397
  #
321
- def self.restore(file)
398
+ def self.restore(session, file)
322
399
  obj = allocate
323
- obj.send(:from_file, file)
400
+ obj.send(:from_file, session, file)
324
401
  obj
325
402
  end
326
403
 
327
404
  #
328
- private def from_file(file)
329
- initialize(file)
405
+ private def from_file(session, file)
330
406
  hash = JSON.parse(IO.read(file), symbolize_names: true)
407
+ @loaded_tasks = ObjectSet.new
408
+ @id = hash.extract(:volume)
409
+ @session = session
410
+ @file = file
331
411
  raise(Volume::Error, %(unsupported Mclone volume format version "#{version}")) unless hash.extract(:mclone) == VERSION
332
- @id = hash.extract(:volume).to_s
333
- hash.extract(:tasks).each { |t| @tasks << Task.restore(t) }
334
- end
335
-
336
- #
337
- def dirty?
338
- tasks.modified?
412
+ hash.dig(:tasks)&.each { |h| session.tasks << (@loaded_tasks << Task.restore(@session, h)) }
413
+ self
339
414
  end
340
415
 
341
416
  #
@@ -348,23 +423,43 @@ module Mclone
348
423
  equal?(other) || id == other.id
349
424
  end
350
425
 
426
+ #
427
+ def modified?
428
+ # Comparison against the original loaded tasks set allows to account for task removals
429
+ (ts = tasks).modified? || ts != @loaded_tasks
430
+ end
431
+
351
432
  #
352
433
  def commit!(force = false)
353
- if force || dirty?
354
- open(file, 'w') do |stream|
355
- stream << JSON.pretty_generate(to_h)
356
- tasks.commit!
434
+ if force || @session.force? || modified?
435
+ # As a safeguard against malformed volume files generation, first write to a new file
436
+ # and rename it to a real volume file only in case of normal turn of events
437
+ _file = "#{file}~"
438
+ begin
439
+ open(_file, 'w') do |stream|
440
+ stream << JSON.pretty_generate(to_h)
441
+ tasks.commit!
442
+ end
443
+ FileUtils.mv(_file, file, force: true)
444
+ ensure
445
+ FileUtils.rm_f(_file)
357
446
  end
358
447
  end
448
+ self
449
+ end
450
+
451
+ #
452
+ def tasks
453
+ TaskSet.new(self).merge!(@session.tasks)
359
454
  end
360
455
 
361
456
  #
362
457
  def to_h
363
- {mclone: VERSION, volume: id, tasks: tasks.collect(&:to_h)}
458
+ { mclone: VERSION, volume: id, tasks: tasks.collect { |task| task.to_h(self) } }
364
459
  end
365
460
 
366
461
  # Volume-bound set of tasks belonging to the specific volume
367
- class VolumeTaskSet < Mclone::TaskSet
462
+ class TaskSet < Mclone::TaskSet
368
463
 
369
464
  def initialize(volume)
370
465
  @volume = volume
@@ -388,12 +483,9 @@ module Mclone
388
483
  #
389
484
  def resolve(id)
390
485
  case (ids = super).size
391
- when 0
392
- raise(Volume::Error, %(no volume matching "#{id}" pattern found))
393
- when 1
394
- ids.first
395
- else
396
- raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
486
+ when 0 then raise(Volume::Error, %(no volume matching "#{id}" pattern found))
487
+ when 1 then ids.first
488
+ else raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
397
489
  end
398
490
  end
399
491
 
@@ -429,31 +521,33 @@ module Mclone
429
521
  #
430
522
  attr_writer :simulate, :verbose, :force
431
523
 
524
+ #
525
+ attr_reader :tasks
526
+
432
527
  #
433
528
  def initialize
434
529
  @volumes = VolumeSet.new
530
+ @tasks = SessionTaskSet.new(self)
435
531
  end
436
532
 
437
533
  #
438
534
  def format_volume!(dir)
439
535
  mclone = File.join(dir, Volume::FILE)
440
536
  raise(Session::Error, %(refuse to overwrite existing Mclone volume file "#{mclone}")) if File.exist?(mclone) && !force?
441
- volume = Volume.new(mclone)
442
- @volumes << volume
443
- volume.commit!(true) unless simulate? # Force creating a new (empty) volume
537
+ volumes << (volume = Volume.new(self, mclone))
538
+ volume.commit!(true) unless simulate? # Force creation of a new (empty) volume
444
539
  self
445
540
  end
446
541
 
447
542
  #
448
543
  def restore_volume!(dir)
449
- volume = Volume.restore(File.join(dir, Volume::FILE))
450
- @volumes << volume
544
+ volumes << Volume.restore(self, File.join(dir, Volume::FILE))
451
545
  self
452
546
  end
453
547
 
454
548
  #
455
549
  def restore_volumes!
456
- (Mclone.environment_mounts + Mclone.system_mounts).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
550
+ (Mclone.environment_mounts + Mclone.system_mounts + [ENV['HOME']]).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
457
551
  self
458
552
  end
459
553
 
@@ -467,84 +561,83 @@ module Mclone
467
561
  end
468
562
 
469
563
  #
470
- def create_task!(source, destination, mode: :update, include: '**', exclude: '')
471
- task = Task.new(*locate(source), *locate(destination))
472
- task.mode = mode
473
- task.include = include
474
- task.exclude = exclude
475
- volumes.each do |volume|
476
- t = volume.tasks[task]
477
- raise(Session::Error, %(refuse to overwrite existing task "#{t.id}")) unless t.nil? || force?
478
- volume.tasks << task # It is a volume's responsibility to collect appropriate tasks, see Volume::TaskSet#<<
479
- end
564
+ def create_task!(mode, source, destination, **kws)
565
+ task = Task.new(self, mode, *locate(source), *locate(destination), **kws)
566
+ _task = tasks[task]
567
+ raise(Session::Error, %(refuse to overwrite existing task "#{_task.id}")) unless _task.nil? || force?
568
+ tasks << task
480
569
  self
481
570
  end
482
571
 
483
572
  #
484
- def modify_task!(id, mode:, include:, exclude:)
573
+ def modify_task!(id, mode: nil, include: nil, exclude: nil)
485
574
  ts = tasks
486
575
  task = ts.task(ts.resolve(id)).clone
487
- task.mode = mode
488
- task.include = include
489
- task.exclude = exclude
490
- volumes.each { |volume| volume.tasks << task }
576
+ task.mode = mode unless mode.nil?
577
+ task.include = include unless include.nil?
578
+ task.exclude = exclude unless exclude.nil?
579
+ tasks << task
491
580
  self
492
581
  end
493
582
 
494
583
  #
495
584
  def delete_task!(id)
496
- ts = tasks
497
- task = ts.task(ts.resolve(id))
498
- volumes.each { |volume| volume.tasks >> task }
585
+ tasks >> tasks.task(tasks.resolve(id))
499
586
  self
500
587
  end
501
588
 
502
589
  #
503
590
  def process_tasks!(*ids)
504
- ts = intact_tasks
505
- ids = ts.collect(&:id) if ids.empty?
506
- rclone = 'rclone' if (rclone = ENV['RCLONE']).nil?
507
- ids.collect { |id| ts.task(ts.resolve(id)) }.each do |task|
508
- args = [rclone]
591
+ failed = false
592
+ intacts = intact_tasks
593
+ ids = intacts.collect(&:id) if ids.empty?
594
+ ids.collect { |id| intacts.task(intacts.resolve(id)) }.each do |task|
595
+ source_path = File.join(volumes.volume(task.source_id).root, task.source_root.nil? || task.source_root.empty? ? '' : task.source_root)
596
+ destination_path = File.join(volumes.volume(task.destination_id).root, task.destination_root.nil? || task.destination_root.empty? ? '' : task.destination_root)
597
+ args = [Mclone.rclone]
509
598
  opts = [
599
+ '--config', Mclone.windows? ? 'NUL' : '/dev/null',
510
600
  simulate? ? '--dry-run' : nil,
511
- verbose? ? '--verbose' : nil
601
+ verbose? ? '--verbose' : nil,
602
+ verbose? ? '--progress' : nil
512
603
  ].compact
604
+ opts.append('--crypt-password', task.crypter_token) unless task.crypter_mode.nil?
605
+ case task.crypter_mode
606
+ when :encrypt then opts.append('--crypt-remote', destination_path)
607
+ when :decrypt then opts.append('--crypt-remote', source_path)
608
+ end
513
609
  case task.mode
514
- when :update
515
- args << 'copy'
516
- opts << '--update'
517
- when :synchronize
518
- args << 'sync'
519
- when :copy
520
- args << 'copy'
521
- when :move
522
- args << 'move'
610
+ when :update then args.push('copy', '--update')
611
+ when :synchronize then args << 'sync'
612
+ when :copy then args << 'copy'
613
+ when :move then args << 'move'
523
614
  end
524
615
  opts.append('--filter', "- /#{Volume::FILE}")
525
616
  opts.append('--filter', "- #{task.exclude}") unless task.exclude.nil? || task.exclude.empty?
526
617
  opts.append('--filter', "+ #{task.include}") unless task.include.nil? || task.include.empty?
527
618
  args.concat(opts)
528
- args.append(File.join(volumes.volume(task.source_id).root, task.source_root), File.join(volumes.volume(task.destination_id).root, task.destination_root))
619
+ case task.crypter_mode
620
+ when nil then args.append(source_path, destination_path)
621
+ when :encrypt then args.append(source_path, ':crypt:')
622
+ when :decrypt then args.append(':crypt:', destination_path)
623
+ end
624
+ $stdout << args.collect(&:escape).join(' ') << "\n" if verbose?
529
625
  case system(*args)
530
- when nil then raise(Session::Error, %(failed to execute "#{args.first}"))
531
- when false then exit($?)
626
+ when nil
627
+ $stderr << %(failed to execute "#{args.first}") << "\n" if verbose?
628
+ failed = true
629
+ when false
630
+ $stderr << %(Rclone exited with status #{$?.to_i}) << "\n" if verbose?
631
+ failed = true
532
632
  end
533
633
  end
534
- end
535
-
536
- # Collect all tasks from all loaded volumes
537
- def tasks
538
- tasks = SessionTaskSet.new(self)
539
- volumes.each { |volume| tasks.merge!(volume.tasks) }
540
- tasks
634
+ raise(Session::Error, "Rclone execution failure(s)") if failed
635
+ self
541
636
  end
542
637
 
543
638
  # Collect all tasks from all loaded volumes which are ready to be executed
544
639
  def intact_tasks
545
- tasks = IntactTaskSet.new(self)
546
- volumes.each { |volume| tasks.merge!(volume.tasks) }
547
- tasks
640
+ IntactTaskSet.new(self).merge!(tasks)
548
641
  end
549
642
 
550
643
  #
@@ -587,6 +680,11 @@ module Mclone
587
680
 
588
681
  end
589
682
 
683
+ #
684
+ def self.rclone
685
+ @@rclone ||= (rclone = ENV['RCLONE']).nil? ? 'rclone' : rclone
686
+ end
687
+
590
688
  # Return true if run in the real Windows environment (e.g. not in real *NIX or various emulation layers such as MSYS, Cygwin etc.)
591
689
  def self.windows?
592
690
  @@windows ||= /^(mingw)/.match?(RbConfig::CONFIG['target_os']) # RubyInstaller's MRI, other MinGW-build MRI
@@ -600,17 +698,16 @@ module Mclone
600
698
  PATH_LIST_SEPARATOR = windows? ? ';' : ':'
601
699
 
602
700
  # Return list of live user-provided mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
603
- # Look for the $mclone_PATH environment variable
701
+ # Look for the $MCLONE_PATH environment variable
604
702
  def self.environment_mounts
605
703
  ENV['MCLONE_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
606
704
  end
607
-
608
705
  # Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
609
706
  case RbConfig::CONFIG['target_os']
610
707
  when 'linux'
611
708
  # Linux OS
612
709
  def self.system_mounts
613
- # Query on /proc for currently mounted file systems
710
+ # Query /proc for currently mounted file systems
614
711
  IO.readlines('/proc/self/mountstats').collect do |line|
615
712
  mount = line.split[4]
616
713
  UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount