mclone 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +16 -8
  3. data/README.md +409 -409
  4. data/bin/mclone +1 -157
  5. data/lib/mclone/cli.rb +157 -0
  6. data/lib/mclone.rb +752 -745
  7. metadata +4 -3
data/lib/mclone.rb CHANGED
@@ -1,746 +1,753 @@
1
- # frozen_string_literal: true
2
-
3
-
4
- require 'date'
5
- require 'json'
6
- require 'open3'
7
- require 'fileutils'
8
- require 'shellwords'
9
- require 'securerandom'
10
-
11
-
12
- #
13
- module Mclone
14
-
15
-
16
- VERSION = '0.2.1'
17
-
18
-
19
- #
20
- class Error < StandardError; end
21
-
22
- #
23
- module Refinements
24
-
25
- refine ::Hash do
26
- # Same as #dig but raises KeyError exception on any non-existent key
27
- def extract(*args)
28
- case args.size
29
- when 0 then raise(KeyError, 'non-empty key sequence expected')
30
- when 1 then fetch(args.first)
31
- else fetch(args.shift).extract(*args)
32
- end
33
- end
34
- end
35
-
36
- refine ::Array do
37
- # Return a list of items which fully or partially match the specified pattern
38
- def resolve(partial)
39
- rx = Regexp.new(partial)
40
- collect { |item| rx.match?(item.to_s) ? item : nil }.compact
41
- end
42
- end
43
-
44
- refine ::String do
45
- def escape
46
- Mclone.windows? && %r![^\w\-\=\\\/:]!.match?(self) ? %("#{self}") : shellescape
47
- end
48
- end
49
- end
50
-
51
-
52
- using Refinements
53
-
54
-
55
- # Two-way mapping between an object and its ID
56
- class ObjectSet
57
-
58
- include Enumerable
59
-
60
- #
61
- def each_id(&code)
62
- @ids.each_key(&code)
63
- end
64
-
65
- #
66
- def each(&code)
67
- @objects.each_value(&code)
68
- end
69
-
70
- #
71
- def empty?
72
- @objects.empty?
73
- end
74
-
75
- #
76
- def size
77
- @objects.size
78
- end
79
-
80
- def initialize
81
- @ids = {} # { id => object }
82
- @objects = {} # { object => object }
83
- @modified = false
84
- end
85
-
86
- attr_reader :objects; protected :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
-
100
- # Return ID of the object considered equal to the specified obj or nil
101
- def id(obj)
102
- @objects[obj]&.id
103
- end
104
-
105
- # Return object with specified ID or nil
106
- def object(id)
107
- @ids[id]
108
- end
109
-
110
- # Return object considered equal to obj or nil
111
- def [](obj)
112
- @objects[obj]
113
- end
114
-
115
- #
116
- def modified?
117
- @modified
118
- end
119
-
120
- def commit!
121
- @modified = false
122
- self
123
- end
124
-
125
- # Unregister an object considered equal to the specified obj and return true if object has been actually removed
126
- private def forget(obj)
127
- !@ids.delete(@objects.delete(obj)&.id).nil?
128
- end
129
-
130
- # Return a list of registered IDs (fully or partially) matching the specified pattern
131
- def resolve(pattern)
132
- each_id.to_a.resolve(pattern)
133
- end
134
-
135
- # Either add brand new object or replace existing one equal to the specified object
136
- def <<(obj)
137
- forget(obj)
138
- @objects[obj] = @ids[obj.id] = obj
139
- @modified = true
140
- obj
141
- end
142
-
143
- # Remove object considered equal to the specified obj
144
- def >>(obj)
145
- @modified = true if (status = forget(obj))
146
- status
147
- end
148
-
149
- # Add all tasks from enumerable
150
- def merge!(objs)
151
- objs.each { |x| self << x }
152
- self
153
- end
154
-
155
- end
156
-
157
-
158
- #
159
- class Task
160
-
161
- #
162
- class Error < Mclone::Error
163
- end
164
-
165
- #
166
- attr_reader :id
167
-
168
- #
169
- attr_reader :source_id, :destination_id
170
-
171
- #
172
- attr_reader :source_root, :destination_root
173
-
174
- #
175
- attr_reader :mtime
176
-
177
- #
178
- attr_reader :mode
179
-
180
- #
181
- attr_reader :include, :exclude
182
-
183
- #
184
- attr_reader :crypter_mode
185
-
186
- def hash
187
- @hash ||= source_id.hash ^ destination_id.hash ^ source_root.hash ^ destination_root.hash
188
- end
189
-
190
- def eql?(other)
191
- equal?(other) || (
192
- source_id == other.source_id &&
193
- destination_id == other.destination_id &&
194
- source_root == other.source_root &&
195
- destination_root == other.destination_root
196
- )
197
- end
198
-
199
- alias == eql?
200
-
201
- #
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)
203
- @touch = false # Indicates that the time stamp should be updated whenever state of self is altered
204
- @session = session
205
- @id = SecureRandom.hex(4)
206
- @source_id = source_id
207
- @destination_id = destination_id
208
- @source_root = source_root
209
- @destination_root = destination_root
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
219
- ensure
220
- @touch = true
221
- touch!
222
- end
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
-
260
- #
261
- MODES = %i[update synchronize copy move].freeze
262
-
263
- #
264
- def mode=(mode)
265
- @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
266
- touch!
267
- mode
268
- end
269
-
270
- #
271
- def include=(mask)
272
- @include = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
273
- touch!
274
- mask
275
- end
276
-
277
- #
278
- def exclude=(mask)
279
- @exclude = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
280
- touch!
281
- mask
282
- end
283
-
284
- #
285
- def self.restore(session, hash)
286
- obj = allocate
287
- obj.send(:from_h, session, hash)
288
- obj
289
- end
290
-
291
- #
292
- private def from_h(session, hash)
293
- @session = session
294
- @touch = false
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)
301
- self.mode = hash.extract(:mode)
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?
306
- ensure
307
- @touch = true
308
- end
309
-
310
- #
311
- def to_h(volume)
312
- hash = {
313
- task: id,
314
- mode: mode,
315
- mtime: mtime,
316
- source: { volume: source_id },
317
- destination: { volume: destination_id }
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
329
- end
330
-
331
- #
332
- def touch!
333
- @mtime = DateTime.now if @touch
334
- self
335
- end
336
- end
337
-
338
-
339
- #
340
- class TaskSet < ObjectSet
341
-
342
- alias task object
343
-
344
- # Add new task or replace existing one with outdated timestamp
345
- def <<(task)
346
- t = self[task]
347
- super if t.nil? || (!t.nil? && t.mtime < task.mtime)
348
- task
349
- end
350
-
351
- #
352
- def resolve(id)
353
- case (ids = super).size
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))
357
- end
358
- end
359
-
360
- end
361
-
362
-
363
- #
364
- class Volume
365
-
366
- #
367
- class Error < Mclone::Error
368
-
369
- end
370
-
371
- #
372
- VERSION = 0
373
-
374
- #
375
- FILE = '.mclone'
376
-
377
- #
378
- attr_reader :id
379
-
380
- #
381
- attr_reader :file
382
-
383
-
384
- #
385
- def root
386
- @root ||= File.realpath(File.dirname(file))
387
- end
388
-
389
- #
390
- def initialize(session, file)
391
- @loaded_tasks = ObjectSet.new
392
- @id = SecureRandom.hex(4)
393
- @session = session
394
- @file = file
395
- end
396
-
397
- #
398
- def self.restore(session, file)
399
- obj = allocate
400
- obj.send(:from_file, session, file)
401
- obj
402
- end
403
-
404
- #
405
- private def from_file(session, file)
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
411
- raise(Volume::Error, %(unsupported Mclone volume format version "#{version}")) unless hash.extract(:mclone) == VERSION
412
- hash.dig(:tasks)&.each { |h| session.tasks << (@loaded_tasks << Task.restore(@session, h)) }
413
- self
414
- end
415
-
416
- #
417
- def hash
418
- id.hash
419
- end
420
-
421
- #
422
- def eql?(other)
423
- equal?(other) || id == other.id
424
- end
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
-
432
- #
433
- def commit!(force = false)
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)
446
- end
447
- end
448
- self
449
- end
450
-
451
- #
452
- def tasks
453
- TaskSet.new(self).merge!(@session.tasks)
454
- end
455
-
456
- #
457
- def to_h
458
- { mclone: VERSION, volume: id, tasks: tasks.collect { |task| task.to_h(self) } }
459
- end
460
-
461
- # Volume-bound set of tasks belonging to the specific volume
462
- class TaskSet < Mclone::TaskSet
463
-
464
- def initialize(volume)
465
- @volume = volume
466
- super()
467
- end
468
-
469
- # Accept only the tasks referencing the volume as either source or destination
470
- def <<(task)
471
- task.source_id == @volume.id || task.destination_id == @volume.id ? super : task
472
- end
473
-
474
- end
475
- end
476
-
477
-
478
- #
479
- class VolumeSet < ObjectSet
480
-
481
- alias volume object
482
-
483
- #
484
- def resolve(id)
485
- case (ids = super).size
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))
489
- end
490
- end
491
-
492
- end
493
-
494
-
495
- #
496
- class Session
497
-
498
- #
499
- class Error < Mclone::Error
500
-
501
- end
502
-
503
- #
504
- attr_reader :volumes
505
-
506
- #
507
- def simulate?
508
- @simulate == true
509
- end
510
-
511
- #
512
- def verbose?
513
- @verbose == true
514
- end
515
-
516
- #
517
- def force?
518
- @force == true
519
- end
520
-
521
- #
522
- attr_writer :simulate, :verbose, :force
523
-
524
- #
525
- attr_reader :tasks
526
-
527
- #
528
- def initialize
529
- @volumes = VolumeSet.new
530
- @tasks = SessionTaskSet.new(self)
531
- end
532
-
533
- #
534
- def format_volume!(dir)
535
- mclone = File.join(dir, Volume::FILE)
536
- raise(Session::Error, %(refuse to overwrite existing Mclone volume file "#{mclone}")) if File.exist?(mclone) && !force?
537
- volumes << (volume = Volume.new(self, mclone))
538
- volume.commit!(true) unless simulate? # Force creation of a new (empty) volume
539
- self
540
- end
541
-
542
- #
543
- def restore_volume!(dir)
544
- volumes << Volume.restore(self, File.join(dir, Volume::FILE))
545
- self
546
- end
547
-
548
- #
549
- def restore_volumes!
550
- (Mclone.environment_mounts + Mclone.system_mounts + [ENV['HOME']]).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
551
- self
552
- end
553
-
554
- #
555
- def delete_volume!(id)
556
- volume = volumes.volume(id = volumes.resolve(id))
557
- raise(Session::Error, %(refuse to delete non-empty Mclone volume file "#{volume.file}")) unless volume.tasks.empty? || force?
558
- volumes >> volume
559
- FileUtils.rm_f(volume.file) unless simulate?
560
- self
561
- end
562
-
563
- #
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
569
- self
570
- end
571
-
572
- #
573
- def modify_task!(id, mode: nil, include: nil, exclude: nil)
574
- ts = tasks
575
- task = ts.task(ts.resolve(id)).clone
576
- task.mode = mode unless mode.nil?
577
- task.include = include unless include.nil?
578
- task.exclude = exclude unless exclude.nil?
579
- tasks << task
580
- self
581
- end
582
-
583
- #
584
- def delete_task!(id)
585
- tasks >> tasks.task(tasks.resolve(id))
586
- self
587
- end
588
-
589
- #
590
- def process_tasks!(*ids)
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]
598
- opts = [
599
- '--config', Mclone.windows? ? 'NUL' : '/dev/null',
600
- simulate? ? '--dry-run' : nil,
601
- verbose? ? '--verbose' : nil,
602
- verbose? ? '--progress' : nil
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
609
- case task.mode
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'
614
- end
615
- opts.append('--filter', "- /#{Volume::FILE}")
616
- opts.append('--filter', "- #{task.exclude}") unless task.exclude.nil? || task.exclude.empty?
617
- opts.append('--filter', "+ #{task.include}") unless task.include.nil? || task.include.empty?
618
- args.concat(opts)
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?
625
- case system(*args)
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
632
- end
633
- end
634
- raise(Session::Error, "Rclone execution failure(s)") if failed
635
- self
636
- end
637
-
638
- # Collect all tasks from all loaded volumes which are ready to be executed
639
- def intact_tasks
640
- IntactTaskSet.new(self).merge!(tasks)
641
- end
642
-
643
- #
644
- private def locate(path)
645
- path = File.realpath(path)
646
- x = volumes.each.collect { |v| Regexp.new(%!^#{v.root}/?(.*)!, Mclone.windows? ? Regexp::IGNORECASE : nil) =~ path ? [v.root, v.id, $1] : nil }.compact
647
- if x.empty?
648
- raise(Session::Error, %(path "#{path}" does not belong to a loaded Mclone volume))
649
- else
650
- root, volume, path = x.sort { |a,b| a.first.size <=> b.first.size}.last
651
- [volume, path]
652
- end
653
- end
654
-
655
- #
656
- def commit!
657
- volumes.each { |v| v.commit!(force?) } unless simulate?
658
- self
659
- end
660
-
661
- #
662
- class SessionTaskSet < Mclone::TaskSet
663
-
664
- def initialize(session)
665
- @session = session
666
- super()
667
- end
668
-
669
- end
670
-
671
- # Session-bound set of intact tasks for which both source and destination volumes are loaded
672
- class IntactTaskSet < SessionTaskSet
673
-
674
- # Accept only intact tasks for which both source and destination volumes are loaded
675
- def <<(task)
676
- @session.volumes.volume(task.source_id).nil? || @session.volumes.volume(task.destination_id).nil? ? task : super
677
- end
678
-
679
- end
680
-
681
- end
682
-
683
- #
684
- def self.rclone
685
- @@rclone ||= (rclone = ENV['RCLONE']).nil? ? 'rclone' : rclone
686
- end
687
-
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.)
689
- def self.windows?
690
- @@windows ||= /^(mingw)/.match?(RbConfig::CONFIG['target_os']) # RubyInstaller's MRI, other MinGW-build MRI
691
- end
692
-
693
- # Match OS-specific system mount points (/dev /proc etc.) which normally should be omitted when scanning for Mclone voulmes
694
- UNIX_SYSTEM_MOUNTS = %r!^/(dev|sys|proc)!
695
-
696
- # TODO handle Windows variants
697
- # Specify OS-specific path name list separator (such as in the $PATH environment variable)
698
- PATH_LIST_SEPARATOR = windows? ? ';' : ':'
699
-
700
- # Return list of live user-provided mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
701
- # Look for the $MCLONE_PATH environment variable
702
- def self.environment_mounts
703
- ENV['MCLONE_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
704
- end
705
- # Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
706
- case RbConfig::CONFIG['target_os']
707
- when 'linux'
708
- # Linux OS
709
- def self.system_mounts
710
- # Query /proc for currently mounted file systems
711
- IO.readlines('/proc/self/mountstats').collect do |line|
712
- mount = line.split[4]
713
- UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
714
- end.compact
715
- end
716
- # TODO handle Windows variants
717
- when /^mingw/ # RubyInstaller's MRI
718
- module Kernel32
719
- require 'fiddle'
720
- require 'fiddle/types'
721
- require 'fiddle/import'
722
- extend Fiddle::Importer
723
- dlload('kernel32')
724
- include Fiddle::Win32Types
725
- extern 'DWORD WINAPI GetLogicalDrives()'
726
- end
727
- def self.system_mounts
728
- mounts = []
729
- mask = Kernel32.GetLogicalDrives
730
- ('A'..'Z').each do |x|
731
- mounts << "#{x}:" if mask & 1 == 1
732
- mask >>= 1
733
- end
734
- mounts
735
- end
736
- else
737
- # Generic *NIX-like OS, including Cygwin & MSYS(2)
738
- def self.system_mounts
739
- # Use $(mount) system utility to obtain currently mounted file systems
740
- %x(mount).split("\n").collect do |line|
741
- mount = line.split[2]
742
- UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
743
- end.compact
744
- end
745
- end
1
+ # frozen_string_literal: true
2
+
3
+
4
+ require 'date'
5
+ require 'json'
6
+ require 'open3'
7
+ require 'fileutils'
8
+ require 'shellwords'
9
+ require 'securerandom'
10
+
11
+
12
+ #
13
+ module Mclone
14
+
15
+
16
+ VERSION = '0.3.0'
17
+
18
+
19
+ #
20
+ class Error < StandardError; end
21
+
22
+ #
23
+ module Refinements
24
+
25
+ refine ::Hash do
26
+ # Same as #dig but raises KeyError exception on any non-existent key
27
+ def extract(*args)
28
+ case args.size
29
+ when 0 then raise(KeyError, 'non-empty key sequence expected')
30
+ when 1 then fetch(args.first)
31
+ else fetch(args.shift).extract(*args)
32
+ end
33
+ end
34
+ end
35
+
36
+ refine ::Array do
37
+ # Return a list of items which fully or partially match the specified pattern
38
+ def resolve(partial)
39
+ rx = Regexp.new(partial)
40
+ collect { |item| rx.match?(item.to_s) ? item : nil }.compact
41
+ end
42
+ end
43
+
44
+ refine ::String do
45
+ def escape
46
+ Mclone.windows? && %r![^\w\-\=\\\/:]!.match?(self) ? %("#{self}") : shellescape
47
+ end
48
+ end
49
+ end
50
+
51
+
52
+ using Refinements
53
+
54
+
55
+ # Two-way mapping between an object and its ID
56
+ class ObjectSet
57
+
58
+ include Enumerable
59
+
60
+ #
61
+ def each_id(&code)
62
+ @ids.each_key(&code)
63
+ end
64
+
65
+ #
66
+ def each(&code)
67
+ @objects.each_value(&code)
68
+ end
69
+
70
+ #
71
+ def empty?
72
+ @objects.empty?
73
+ end
74
+
75
+ #
76
+ def size
77
+ @objects.size
78
+ end
79
+
80
+ def initialize
81
+ @ids = {} # { id => object }
82
+ @objects = {} # { object => object }
83
+ @modified = false
84
+ end
85
+
86
+ attr_reader :objects; protected :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
+
100
+ # Return ID of the object considered equal to the specified obj or nil
101
+ def id(obj)
102
+ @objects[obj]&.id
103
+ end
104
+
105
+ # Return object with specified ID or nil
106
+ def object(id)
107
+ @ids[id]
108
+ end
109
+
110
+ # Return object considered equal to obj or nil
111
+ def [](obj)
112
+ @objects[obj]
113
+ end
114
+
115
+ #
116
+ def modified?
117
+ @modified
118
+ end
119
+
120
+ def commit!
121
+ @modified = false
122
+ self
123
+ end
124
+
125
+ # Unregister an object considered equal to the specified obj and return true if object has been actually removed
126
+ private def forget(obj)
127
+ !@ids.delete(@objects.delete(obj)&.id).nil?
128
+ end
129
+
130
+ # Return a list of registered IDs (fully or partially) matching the specified pattern
131
+ def resolve(pattern)
132
+ each_id.to_a.resolve(pattern)
133
+ end
134
+
135
+ # Either add brand new object or replace existing one equal to the specified object
136
+ def <<(obj)
137
+ forget(obj)
138
+ @objects[obj] = @ids[obj.id] = obj
139
+ @modified = true
140
+ obj
141
+ end
142
+
143
+ # Remove object considered equal to the specified obj
144
+ def >>(obj)
145
+ @modified = true if (status = forget(obj))
146
+ status
147
+ end
148
+
149
+ # Add all tasks from enumerable
150
+ def merge!(objs)
151
+ objs.each { |x| self << x }
152
+ self
153
+ end
154
+
155
+ end
156
+
157
+
158
+ #
159
+ class Task
160
+
161
+ #
162
+ class Error < Mclone::Error
163
+ end
164
+
165
+ #
166
+ attr_reader :id
167
+
168
+ #
169
+ attr_reader :source_id, :destination_id
170
+
171
+ #
172
+ attr_reader :source_root, :destination_root
173
+
174
+ #
175
+ attr_reader :mtime
176
+
177
+ #
178
+ attr_reader :mode
179
+
180
+ #
181
+ attr_reader :include, :exclude
182
+
183
+ #
184
+ attr_reader :crypter_mode
185
+
186
+ def hash
187
+ @hash ||= source_id.hash ^ destination_id.hash ^ source_root.hash ^ destination_root.hash
188
+ end
189
+
190
+ def eql?(other)
191
+ equal?(other) || (
192
+ source_id == other.source_id &&
193
+ destination_id == other.destination_id &&
194
+ source_root == other.source_root &&
195
+ destination_root == other.destination_root
196
+ )
197
+ end
198
+
199
+ alias == eql?
200
+
201
+ #
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)
203
+ @touch = false # Indicates that the time stamp should be updated whenever state of self is altered
204
+ @session = session
205
+ @id = SecureRandom.hex(4)
206
+ @source_id = source_id
207
+ @destination_id = destination_id
208
+ @source_root = source_root
209
+ @destination_root = destination_root
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
219
+ ensure
220
+ @touch = true
221
+ touch!
222
+ end
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
+
260
+ #
261
+ MODES = %i[update synchronize copy move].freeze
262
+
263
+ #
264
+ def mode=(mode)
265
+ @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
266
+ touch!
267
+ mode
268
+ end
269
+
270
+ #
271
+ def include=(mask)
272
+ @include = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
273
+ touch!
274
+ mask
275
+ end
276
+
277
+ #
278
+ def exclude=(mask)
279
+ @exclude = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
280
+ touch!
281
+ mask
282
+ end
283
+
284
+ #
285
+ def self.restore(session, hash)
286
+ obj = allocate
287
+ obj.send(:from_h, session, hash)
288
+ obj
289
+ end
290
+
291
+ #
292
+ private def from_h(session, hash)
293
+ @session = session
294
+ @touch = false
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)
301
+ self.mode = hash.extract(:mode)
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?
306
+ ensure
307
+ @touch = true
308
+ end
309
+
310
+ #
311
+ def to_h(volume)
312
+ hash = {
313
+ task: id,
314
+ mode: mode,
315
+ mtime: mtime,
316
+ source: { volume: source_id },
317
+ destination: { volume: destination_id }
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
329
+ end
330
+
331
+ #
332
+ def touch!
333
+ @mtime = DateTime.now if @touch
334
+ self
335
+ end
336
+ end
337
+
338
+
339
+ #
340
+ class TaskSet < ObjectSet
341
+
342
+ alias task object
343
+
344
+ # Add new task or replace existing one with outdated timestamp
345
+ def <<(task)
346
+ t = self[task]
347
+ super if t.nil? || (!t.nil? && t.mtime < task.mtime)
348
+ task
349
+ end
350
+
351
+ #
352
+ def resolve(id)
353
+ case (ids = super).size
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))
357
+ end
358
+ end
359
+
360
+ end
361
+
362
+
363
+ #
364
+ class Volume
365
+
366
+ #
367
+ class Error < Mclone::Error
368
+
369
+ end
370
+
371
+ #
372
+ VERSION = 0
373
+
374
+ #
375
+ FILE = '.mclone'
376
+
377
+ #
378
+ attr_reader :id
379
+
380
+ #
381
+ attr_reader :file
382
+
383
+
384
+ #
385
+ def root
386
+ @root ||= File.realpath(File.dirname(file))
387
+ end
388
+
389
+ #
390
+ def initialize(session, file)
391
+ @loaded_tasks = ObjectSet.new
392
+ @id = SecureRandom.hex(4)
393
+ @session = session
394
+ @file = file
395
+ end
396
+
397
+ #
398
+ def self.restore(session, file)
399
+ obj = allocate
400
+ obj.send(:from_file, session, file)
401
+ obj
402
+ end
403
+
404
+ #
405
+ private def from_file(session, file)
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
411
+ raise(Volume::Error, %(unsupported Mclone volume format version "#{version}")) unless hash.extract(:mclone) == VERSION
412
+ hash.dig(:tasks)&.each { |h| session.tasks << (@loaded_tasks << Task.restore(@session, h)) }
413
+ self
414
+ end
415
+
416
+ #
417
+ def hash
418
+ id.hash
419
+ end
420
+
421
+ #
422
+ def eql?(other)
423
+ equal?(other) || id == other.id
424
+ end
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
+
432
+ #
433
+ def commit!(force = false)
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)
446
+ end
447
+ end
448
+ self
449
+ end
450
+
451
+ #
452
+ def tasks
453
+ TaskSet.new(self).merge!(@session.tasks)
454
+ end
455
+
456
+ #
457
+ def to_h
458
+ { mclone: VERSION, volume: id, tasks: tasks.collect { |task| task.to_h(self) } }
459
+ end
460
+
461
+ # Volume-bound set of tasks belonging to the specific volume
462
+ class TaskSet < Mclone::TaskSet
463
+
464
+ def initialize(volume)
465
+ @volume = volume
466
+ super()
467
+ end
468
+
469
+ # Accept only the tasks referencing the volume as either source or destination
470
+ def <<(task)
471
+ task.source_id == @volume.id || task.destination_id == @volume.id ? super : task
472
+ end
473
+
474
+ end
475
+ end
476
+
477
+
478
+ #
479
+ class VolumeSet < ObjectSet
480
+
481
+ alias volume object
482
+
483
+ #
484
+ def resolve(id)
485
+ case (ids = super).size
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))
489
+ end
490
+ end
491
+
492
+ end
493
+
494
+
495
+ #
496
+ class Session
497
+
498
+ #
499
+ class Error < Mclone::Error
500
+
501
+ end
502
+
503
+ #
504
+ attr_reader :volumes
505
+
506
+ #
507
+ def simulate?
508
+ @simulate == true
509
+ end
510
+
511
+ #
512
+ def verbose?
513
+ @verbose == true
514
+ end
515
+
516
+ #
517
+ def force?
518
+ @force == true
519
+ end
520
+
521
+ #
522
+ attr_writer :simulate, :verbose, :force
523
+
524
+ #
525
+ attr_reader :tasks
526
+
527
+ #
528
+ def initialize
529
+ @volumes = VolumeSet.new
530
+ @tasks = SessionTaskSet.new(self)
531
+ end
532
+
533
+ #
534
+ def format_volume!(dir)
535
+ mclone = File.join(dir, Volume::FILE)
536
+ raise(Session::Error, %(refuse to overwrite existing Mclone volume file "#{mclone}")) if File.exist?(mclone) && !force?
537
+ volumes << (volume = Volume.new(self, mclone))
538
+ volume.commit!(true) unless simulate? # Force creation of a new (empty) volume
539
+ self
540
+ end
541
+
542
+ #
543
+ def restore_volume!(dir)
544
+ volumes << Volume.restore(self, File.join(dir, Volume::FILE))
545
+ self
546
+ end
547
+
548
+ #
549
+ def restore_volumes!
550
+ (Mclone.environment_mounts + Mclone.system_mounts + [ENV['HOME']]).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
551
+ self
552
+ end
553
+
554
+ #
555
+ def delete_volume!(id)
556
+ volume = volumes.volume(id = volumes.resolve(id))
557
+ raise(Session::Error, %(refuse to delete non-empty Mclone volume file "#{volume.file}")) unless volume.tasks.empty? || force?
558
+ volumes >> volume
559
+ FileUtils.rm_f(volume.file) unless simulate?
560
+ self
561
+ end
562
+
563
+ #
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
569
+ self
570
+ end
571
+
572
+ #
573
+ def modify_task!(id, mode: nil, include: nil, exclude: nil)
574
+ ts = tasks
575
+ task = ts.task(ts.resolve(id)).clone
576
+ task.mode = mode unless mode.nil?
577
+ task.include = include unless include.nil?
578
+ task.exclude = exclude unless exclude.nil?
579
+ tasks << task
580
+ self
581
+ end
582
+
583
+ #
584
+ def delete_task!(id)
585
+ tasks >> tasks.task(tasks.resolve(id))
586
+ self
587
+ end
588
+
589
+ #
590
+ def process_tasks!(*ids)
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]
598
+ opts = [
599
+ '--config', Mclone.windows? ? 'NUL' : '/dev/null',
600
+ simulate? ? '--dry-run' : nil,
601
+ verbose? ? '--verbose' : nil,
602
+ verbose? ? '--progress' : nil
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
609
+ case task.mode
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'
614
+ end
615
+ opts.append('--filter', "- /#{Volume::FILE}")
616
+ opts.append('--filter', "- #{task.exclude}") unless task.exclude.nil? || task.exclude.empty?
617
+ opts.append('--filter', "+ #{task.include}") unless task.include.nil? || task.include.empty?
618
+ args.concat(opts)
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?
625
+ case system(*args)
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
632
+ end
633
+ end
634
+ raise(Session::Error, "Rclone execution failure(s)") if failed
635
+ self
636
+ end
637
+
638
+ # Collect all tasks from all loaded volumes which are ready to be executed
639
+ def intact_tasks
640
+ IntactTaskSet.new(self).merge!(tasks)
641
+ end
642
+
643
+ #
644
+ private def locate(path)
645
+ path = File.realpath(path)
646
+ x = volumes.each.collect { |v| Regexp.new(%!^#{v.root}/?(.*)!, Mclone.windows? ? Regexp::IGNORECASE : nil) =~ path ? [v.root, v.id, $1] : nil }.compact
647
+ if x.empty?
648
+ raise(Session::Error, %(path "#{path}" does not belong to a loaded Mclone volume))
649
+ else
650
+ root, volume, path = x.sort { |a,b| a.first.size <=> b.first.size}.last
651
+ [volume, path]
652
+ end
653
+ end
654
+
655
+ #
656
+ def commit!
657
+ volumes.each { |v| v.commit!(force?) } unless simulate?
658
+ self
659
+ end
660
+
661
+ #
662
+ class SessionTaskSet < Mclone::TaskSet
663
+
664
+ def initialize(session)
665
+ @session = session
666
+ super()
667
+ end
668
+
669
+ end
670
+
671
+ # Session-bound set of intact tasks for which both source and destination volumes are loaded
672
+ class IntactTaskSet < SessionTaskSet
673
+
674
+ # Accept only intact tasks for which both source and destination volumes are loaded
675
+ def <<(task)
676
+ @session.volumes.volume(task.source_id).nil? || @session.volumes.volume(task.destination_id).nil? ? task : super
677
+ end
678
+
679
+ end
680
+
681
+ end
682
+
683
+ #
684
+ def self.rclone
685
+ @@rclone ||= (rclone = ENV['RCLONE']).nil? ? 'rclone' : rclone
686
+ end
687
+
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.)
689
+ def self.windows?
690
+ @@windows ||= /^(mingw)/.match?(RbConfig::CONFIG['target_os']) # RubyInstaller's MRI, other MinGW-build MRI
691
+ end
692
+
693
+ # Match OS-specific system mount points (/dev /proc etc.) which normally should be omitted when scanning for Mclone voulmes
694
+ UNIX_SYSTEM_MOUNTS = %r!^/(dev|sys|proc)!
695
+
696
+ # TODO handle Windows variants
697
+ # Specify OS-specific path name list separator (such as in the $PATH environment variable)
698
+ PATH_LIST_SEPARATOR = windows? ? ';' : ':'
699
+
700
+ # Return list of live user-provided mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
701
+ # Look for the $MCLONE_PATH environment variable
702
+ def self.environment_mounts
703
+ ENV['MCLONE_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
704
+ end
705
+ # Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
706
+ if RUBY_PLATFORM =~ /java/
707
+ require 'java'
708
+ def self.system_mounts
709
+ java.nio.file.FileSystems.getDefault.getFileStores.collect {|x| /^(.*)\s+\(.*\)$/.match(x.to_s)[1]}
710
+ end
711
+ else
712
+ case RbConfig::CONFIG['target_os']
713
+ when 'linux'
714
+ # Linux OS
715
+ def self.system_mounts
716
+ # Query /proc for currently mounted file systems
717
+ IO.readlines('/proc/self/mountstats').collect do |line|
718
+ mount = line.split[4]
719
+ UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
720
+ end.compact
721
+ end
722
+ # TODO handle Windows variants
723
+ when /^mingw/ # RubyInstaller's MRI
724
+ module Kernel32
725
+ require 'fiddle'
726
+ require 'fiddle/types'
727
+ require 'fiddle/import'
728
+ extend Fiddle::Importer
729
+ dlload('kernel32')
730
+ include Fiddle::Win32Types
731
+ extern 'DWORD WINAPI GetLogicalDrives()'
732
+ end
733
+ def self.system_mounts
734
+ mounts = []
735
+ mask = Kernel32.GetLogicalDrives
736
+ ('A'..'Z').each do |x|
737
+ mounts << "#{x}:" if mask & 1 == 1
738
+ mask >>= 1
739
+ end
740
+ mounts
741
+ end
742
+ else
743
+ # Generic *NIX-like OS, including Cygwin & MSYS(2)
744
+ def self.system_mounts
745
+ # Use $(mount) system utility to obtain currently mounted file systems
746
+ %x(mount).split("\n").collect do |line|
747
+ mount = line.split[2]
748
+ UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
749
+ end.compact
750
+ end
751
+ end
752
+ end
746
753
  end