mclone 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mclone.rb +745 -745
  3. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb414361ce06f3977f87735131a0c8de7d392b469e55b6c49554b2ac1766baae
4
- data.tar.gz: 60691ed2d154162a68c8a9262bf7ae90af6023a8bcf0148776d1ed2d00dfa987
3
+ metadata.gz: 10e8e59126b5586e4ed7d68af24aa1c9de5eebfc2e9d0e41df63735577de1a3f
4
+ data.tar.gz: 10b171b2c477c07993641e46bcb27e6c3d3766217d02683e009fbb85a5798cb8
5
5
  SHA512:
6
- metadata.gz: e2c1cbfa056b6ca56bd88c2b7abe93c550a6f1c616d2eb93c98037c3197afc60ae30e7ca060997836ea5ae003da278c86a04989d6c1cef22abd04c93cb8d3e98
7
- data.tar.gz: 395f81ed736663c5309eb52a5d1b63f10c7a546e360bd2827038a6dd6eb7b7881fa770b70a386d51b86acb95bcbf9d5422bdda990319c9c670d2e3f2aa66535e
6
+ metadata.gz: 9a14df6196d48aa0c438c1e3659aa25dc8193e77bdf487f34577c24cfd2e4d9ab41ab1a5fcf31ce0b021404118446467bfbe778d0e82c2ec65a8832f622eb35b
7
+ data.tar.gz: 0fae9170543c940e4089613ec2bf4f1b412ceedacadb214209ae1975369cc695a82c8eafad2660d81e19ed8144870210d037564fb9d03d16f2ed5c6aeafca306
data/lib/mclone.rb CHANGED
@@ -1,746 +1,746 @@
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.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
- 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
-
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|run)!
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.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
746
746
  end