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.
- checksums.yaml +4 -4
- data/lib/mclone.rb +745 -745
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 10e8e59126b5586e4ed7d68af24aa1c9de5eebfc2e9d0e41df63735577de1a3f
|
4
|
+
data.tar.gz: 10b171b2c477c07993641e46bcb27e6c3d3766217d02683e009fbb85a5798cb8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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
|
-
|
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.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
|