mclone 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGES.md +12 -0
- data/README.md +56 -16
- data/bin/mclone +1 -142
- data/lib/mclone/cli.rb +157 -0
- data/lib/mclone.rb +265 -161
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1802e6cb7da3dcfd5bf15e49e91308d6d646365d00a8b1081a551b832928b773
|
4
|
+
data.tar.gz: 68e5dff91daa8803072069a47ac2da0a62ba25d577ee6cb4f534eb34f99e0c79
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1b6b121680b408171f9b4aa8c0a7dabcc9c49ace1ba14dc8476fa4456c46cf6230e0efc27b88ae79a79393163a698c1a25248edd92a09adf732e95d0979c5ac7
|
7
|
+
data.tar.gz: 93a43412ffaa68679f980e68d4e40c5ee87d3f144e87b138b28cfee1fedfa611e22e5ca02f14d6eee3ba5c5603bf02c3a18357ade1ef12d45d45497ed7c95287
|
data/CHANGES.md
CHANGED
data/README.md
CHANGED
@@ -14,22 +14,22 @@ backing up comes back into play.
|
|
14
14
|
|
15
15
|
A sane backup strategy mandates the data copies to be physically separated - be it a next room (building, city or planet)
|
16
16
|
computer or just an external drive. Or, even better, the two computers' storages - a primary, where all activity takes place,
|
17
|
-
a mirror storage which holds the backup, and a portable storage (USB flash disc,
|
17
|
+
a mirror storage which holds the backup, and a portable storage (USB flash disc, external HDD or SSD - whatever) which
|
18
18
|
serves as both an intermediate storage and a means of propagating the changes between the primary and the mirror.
|
19
19
|
|
20
20
|
In a more complex scenario there may be multiple one-way or two-way point-to-point data transfer routes between the storages,
|
21
21
|
employing portable storage as a "shuttle" or a "ferry".
|
22
22
|
|
23
23
|
All in all the synchronization task boils down to copying or synchronizing the contents of two local directories. However,
|
24
|
-
since portable storage is involved, the actual file paths may change between synchronizations as a storage
|
24
|
+
since portable storage is involved, the actual file paths may change between synchronizations as a storage can be
|
25
25
|
mounted under different mount points on *NIX system or change the disk drive on Windows system.
|
26
26
|
|
27
|
-
While the Rclone itself is a great tool for local file synchronization, typing
|
28
|
-
becomes tedious and error prone where
|
27
|
+
While the Rclone itself is a great tool for local file synchronization, typing command line to be executed in this case
|
28
|
+
becomes tedious and error prone where possible cost of error is a backup corruption due to wrong paths or misspelled flags.
|
29
29
|
|
30
30
|
This is where the Mclone comes in.
|
31
|
-
It is designed to automatize the Rclone synchronization process by memorizing
|
32
|
-
|
31
|
+
It is designed to automatize the Rclone synchronization process by memorizing command line options and detecting
|
32
|
+
proper source and destination locations wherever they are.
|
33
33
|
|
34
34
|
## Installation
|
35
35
|
|
@@ -269,6 +269,41 @@ storage in the above way.
|
|
269
269
|
|
270
270
|
Consider a two-way synchronization between two storages with a portable ferry which carries and propagates data in both directions.
|
271
271
|
|
272
|
+
## Encryption
|
273
|
+
|
274
|
+
Encryption is an essential part of the Mclone as it is all about handling portable storage which may by compromised
|
275
|
+
while holding confidential data. Mclone fully relies on [encryption capabilities](https://rclone.org/crypt/) of Rclone,
|
276
|
+
that is an encrypted directory structure can be further treated with the Rclone itself.
|
277
|
+
|
278
|
+
The encryption operation in Mclone is activated during task creation time.
|
279
|
+
The encryption mode is activated with `-e` or `-d` command line flag for encryption or decryption, respectively.
|
280
|
+
It no either flag is specified, the encryption gets turned off.
|
281
|
+
|
282
|
+
When in encryption mode, Mclone recursively takes plain files and directories under the source root and creates encrypted
|
283
|
+
files and directories under the destination root.
|
284
|
+
Conversely, when in decryption mode, Mclone takes encrypted source root and decrypts it into the destination root.
|
285
|
+
Mclone is set up to encrypt not only the files' contents but also the file and directory names themselves. The file sizes,
|
286
|
+
modification times as well as some other metadata are not encrypted, though, as they are required for proper operation
|
287
|
+
the file synchronization mechanism.
|
288
|
+
Note that the encrypted root is a regular directory hierarchy (just with fancy file names) and thus can be treated as such.
|
289
|
+
|
290
|
+
***Be wary that file name encryption has a serious implication on the file name length.***
|
291
|
+
The Rclone [crypt](https://rclone.org/crypt/) documentation states the the individual file or directory name length can
|
292
|
+
not exceed ~143 charactes (although ***bytes*** here would be more correct).
|
293
|
+
As the Rclone accepts UTF-8 encoded names, this estimate generally holds true for the Latin charset only, where
|
294
|
+
a character is encoded with a single byte.
|
295
|
+
For non-Latin characters, which can be encoded with two or even more bytes, the maximum allowed name length be
|
296
|
+
much lower.
|
297
|
+
When Rclone encounters a file name too long to hold, it will refuse to process it.
|
298
|
+
|
299
|
+
Rclone employs symmetric cryptography to do its job, which requires some form of password to be supplied upon task
|
300
|
+
creation.
|
301
|
+
This is done by the `-p` command line flag, which specifies a plain text password used to derive the real encryption key.
|
302
|
+
There is another password-related `-t` command line flag which can be used to directly specify
|
303
|
+
an [Rclone-obscured](https://rclone.org/commands/rclone_obscure/) token.
|
304
|
+
Once created, a task memorizes the encryption key on the ***unencrypted end*** of the source/destination volume pair,
|
305
|
+
so there will be no need to pass it during the task processing.
|
306
|
+
|
272
307
|
## Whats next
|
273
308
|
|
274
309
|
### On-screen help
|
@@ -279,20 +314,25 @@ Every `mclone` (sub)command has its own help page which can be shown with `--hel
|
|
279
314
|
$ mclone task create --help
|
280
315
|
|
281
316
|
Usage:
|
282
|
-
mclone task
|
317
|
+
mclone task new [OPTIONS] SOURCE DESTINATION
|
283
318
|
|
284
319
|
Parameters:
|
285
|
-
SOURCE
|
286
|
-
DESTINATION
|
320
|
+
SOURCE Source path
|
321
|
+
DESTINATION Destination path
|
287
322
|
|
288
323
|
Options:
|
289
|
-
-m, --mode MODE
|
290
|
-
-i, --include PATTERN
|
291
|
-
-x, --exclude PATTERN
|
292
|
-
-
|
293
|
-
-
|
294
|
-
-
|
295
|
-
-
|
324
|
+
-m, --mode MODE Operation mode (update | synchronize | copy | move) (default: "update")
|
325
|
+
-i, --include PATTERN Include paths pattern
|
326
|
+
-x, --exclude PATTERN Exclude paths pattern
|
327
|
+
-d, --decrypt Decrypt source
|
328
|
+
-e, --encrypt Encrypt destination
|
329
|
+
-p, --password PASSWORD Plain text password
|
330
|
+
-t, --token TOKEN Rclone crypt token (obscured password)
|
331
|
+
-f, --force Insist on potentially dangerous actions (default: false)
|
332
|
+
-n, --dry-run Simulation mode with no on-disk modifications (default: false)
|
333
|
+
-v, --verbose Verbose operation (default: false)
|
334
|
+
-V, --version Show version
|
335
|
+
-h, --help print help
|
296
336
|
```
|
297
337
|
|
298
338
|
### File filtering
|
data/bin/mclone
CHANGED
@@ -1,144 +1,3 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
require 'clamp'
|
6
|
-
require 'mclone'
|
7
|
-
|
8
|
-
include Mclone
|
9
|
-
|
10
|
-
begin
|
11
|
-
|
12
|
-
Clamp do
|
13
|
-
|
14
|
-
using Refinements
|
15
|
-
|
16
|
-
option ['-f', '--force'], :flag, 'Insist on potentially dangerous activities', default: false
|
17
|
-
option ['-n', '--dry-run'], :flag, 'Simulation mode with no on-disk modifications', default: false
|
18
|
-
option ['-v', '--verbose'], :flag, 'Verbose operation', default: false
|
19
|
-
|
20
|
-
option ['-V', '--version'], :flag, 'Show version' do
|
21
|
-
puts VERSION
|
22
|
-
exit(true)
|
23
|
-
end
|
24
|
-
|
25
|
-
def session
|
26
|
-
session = Mclone::Session.new
|
27
|
-
session.force = force?
|
28
|
-
session.simulate = dry_run?
|
29
|
-
session.verbose = verbose?
|
30
|
-
session.restore_volumes!
|
31
|
-
session
|
32
|
-
end
|
33
|
-
|
34
|
-
def resolve_mode(mode)
|
35
|
-
case (m = Task::MODES.resolve(mode)).size
|
36
|
-
when 0 then raise(Task::Error, %(no modes matching pattern "#{mode}"))
|
37
|
-
when 1 then m.first
|
38
|
-
else raise(Task::Error, %(ambiguous mode pattern "#{mode}"))
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
|
-
subcommand 'info', 'Output information on volumes & tasks' do
|
43
|
-
def execute
|
44
|
-
s = session
|
45
|
-
$stdout.puts "# Mclone version #{Mclone::VERSION}"
|
46
|
-
$stdout.puts
|
47
|
-
$stdout.puts '## Volumes'
|
48
|
-
$stdout.puts
|
49
|
-
s.volumes.each do |volume|
|
50
|
-
$stdout.puts "* [#{volume.id}] :: (#{volume.root})"
|
51
|
-
end
|
52
|
-
stales = []
|
53
|
-
intacts = []
|
54
|
-
intact_tasks = s.intact_tasks
|
55
|
-
s.tasks.each do |task|
|
56
|
-
ts = (t = intact_tasks[task]).nil? ? "<#{task.id}>" : "[#{task.id}]"
|
57
|
-
svs = s.volumes.volume(task.source_id).nil? ? "<#{task.source_id}>" : "[#{task.source_id}]"
|
58
|
-
dvs = s.volumes.volume(task.destination_id).nil? ? "<#{task.destination_id}>" : "[#{task.destination_id}]"
|
59
|
-
xs = ["* #{ts} :: #{task.mode} #{svs}(#{task.source_root}) -> #{dvs}(#{task.destination_root})"]
|
60
|
-
xs << "include #{task.include}" unless task.include.nil? || task.include.empty?
|
61
|
-
xs << "exclude #{task.exclude}" unless task.exclude.nil? || task.exclude.empty?
|
62
|
-
(t.nil? ? stales : intacts) << xs.join(' :: ')
|
63
|
-
end
|
64
|
-
unless intacts.empty?
|
65
|
-
$stdout.puts
|
66
|
-
$stdout.puts '## Intact tasks'
|
67
|
-
$stdout.puts
|
68
|
-
intacts.each { |x| $stdout.puts x }
|
69
|
-
end
|
70
|
-
unless stales.empty?
|
71
|
-
$stdout.puts
|
72
|
-
$stdout.puts '## Stale tasks'
|
73
|
-
$stdout.puts
|
74
|
-
stales.each { |x| $stdout.puts x }
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
78
|
-
|
79
|
-
subcommand 'volume', 'Volume operations' do
|
80
|
-
|
81
|
-
subcommand ['new', 'create'], 'Create new volume' do
|
82
|
-
parameter 'DIRECTORY', 'Directory to become a Mclone volume'
|
83
|
-
def execute
|
84
|
-
session.format_volume!(directory)
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
|
-
subcommand 'delete', 'Delete existing volume' do
|
89
|
-
parameter 'VOLUME', 'Volume ID pattern'
|
90
|
-
def execute
|
91
|
-
session.delete_volume!(volume).commit!
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
end
|
96
|
-
|
97
|
-
subcommand ['task'], 'Task operations' do
|
98
|
-
|
99
|
-
def self.set_task_opts
|
100
|
-
modes = Task::MODES.collect(&:to_s).join(' | ')
|
101
|
-
option ['-m', '--mode'], 'MODE', "Operation mode (#{modes})", default: Task::MODES.first.to_s
|
102
|
-
option ['-i', '--include'], 'PATTERN', 'Include paths pattern', default: '**'
|
103
|
-
option ['-x', '--exclude'], 'PATTERN', 'Exclude paths pattern'
|
104
|
-
end
|
105
|
-
|
106
|
-
subcommand ['new', 'create'], 'Create new SOURCE -> DESTINATION task' do
|
107
|
-
set_task_opts
|
108
|
-
parameter 'SOURCE', 'Source path'
|
109
|
-
parameter 'DESTINATION', 'Destination path'
|
110
|
-
def execute
|
111
|
-
session.create_task!(source, destination, mode: resolve_mode(mode), include: include, exclude: exclude).commit!
|
112
|
-
end
|
113
|
-
end
|
114
|
-
|
115
|
-
subcommand 'modify', 'Modify existing task' do
|
116
|
-
set_task_opts
|
117
|
-
parameter 'TASK', 'Task ID pattern'
|
118
|
-
def execute
|
119
|
-
session.modify_task!(task, mode: resolve_mode(mode), include: include, exclude: exclude).commit!
|
120
|
-
end
|
121
|
-
end
|
122
|
-
|
123
|
-
subcommand 'delete', 'Delete existing task' do
|
124
|
-
parameter 'TASK', 'Task ID pattern'
|
125
|
-
def execute
|
126
|
-
session.delete_task!(task).commit!
|
127
|
-
end
|
128
|
-
end
|
129
|
-
|
130
|
-
subcommand 'process', 'Process specified tasks' do
|
131
|
-
parameter '[TASK] ...', 'Task ID pattern(s)', attribute_name: :tasks
|
132
|
-
def execute
|
133
|
-
session.process_tasks!(*tasks)
|
134
|
-
end
|
135
|
-
end
|
136
|
-
|
137
|
-
end
|
138
|
-
|
139
|
-
end
|
140
|
-
|
141
|
-
rescue Mclone::Error
|
142
|
-
$stderr.puts "ERROR: #{$!.message}"
|
143
|
-
exit(false)
|
144
|
-
end
|
3
|
+
require 'mclone/cli'
|
data/lib/mclone/cli.rb
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'clamp'
|
4
|
+
require 'mclone'
|
5
|
+
|
6
|
+
include Mclone
|
7
|
+
|
8
|
+
begin
|
9
|
+
|
10
|
+
Clamp do
|
11
|
+
|
12
|
+
using Refinements
|
13
|
+
|
14
|
+
self.default_subcommand = 'info'
|
15
|
+
|
16
|
+
option ['-f', '--force'], :flag, 'Insist on potentially dangerous actions', default: false
|
17
|
+
option ['-n', '--dry-run'], :flag, 'Simulation mode with no on-disk modifications', default: false
|
18
|
+
option ['-v', '--verbose'], :flag, 'Verbose operation', default: false
|
19
|
+
|
20
|
+
option ['-V', '--version'], :flag, 'Show version' do
|
21
|
+
puts VERSION
|
22
|
+
exit(true)
|
23
|
+
end
|
24
|
+
|
25
|
+
def session
|
26
|
+
session = Mclone::Session.new
|
27
|
+
session.force = force?
|
28
|
+
session.simulate = dry_run?
|
29
|
+
session.verbose = verbose?
|
30
|
+
session.restore_volumes!
|
31
|
+
session
|
32
|
+
end
|
33
|
+
|
34
|
+
def resolve_mode(mode)
|
35
|
+
case (m = Task::MODES.resolve(mode)).size
|
36
|
+
when 0 then raise(Task::Error, %(no modes matching pattern "#{mode}"))
|
37
|
+
when 1 then m.first
|
38
|
+
else raise(Task::Error, %(ambiguous mode pattern "#{mode}"))
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
subcommand 'info', 'Output information on volumes & tasks' do
|
43
|
+
def execute
|
44
|
+
s = session
|
45
|
+
$stdout.puts "# Mclone version #{Mclone::VERSION}"
|
46
|
+
$stdout.puts
|
47
|
+
$stdout.puts '## Volumes'
|
48
|
+
$stdout.puts
|
49
|
+
s.volumes.each { |volume| $stdout.puts "* [#{volume.id}] :: (#{volume.root})" }
|
50
|
+
stales = []
|
51
|
+
intacts = []
|
52
|
+
intact_tasks = s.intact_tasks
|
53
|
+
s.tasks.each do |task|
|
54
|
+
ts = (t = intact_tasks[task]).nil? ? "<#{task.id}>" : "[#{task.id}]"
|
55
|
+
svs = s.volumes.volume(task.source_id).nil? ? "<#{task.source_id}>" : "[#{task.source_id}]"
|
56
|
+
dvs = s.volumes.volume(task.destination_id).nil? ? "<#{task.destination_id}>" : "[#{task.destination_id}]"
|
57
|
+
crypter_mode = task.crypter_mode.nil? ? nil : "#{task.crypter_mode}+"
|
58
|
+
xs = ["* #{ts} :: #{crypter_mode}#{task.mode} #{svs}(#{task.source_root}) -> #{dvs}(#{task.destination_root})"]
|
59
|
+
xs << "include #{task.include}" unless task.include.nil? || task.include.empty?
|
60
|
+
xs << "exclude #{task.exclude}" unless task.exclude.nil? || task.exclude.empty?
|
61
|
+
(t.nil? ? stales : intacts) << xs.join(' :: ')
|
62
|
+
end
|
63
|
+
unless intacts.empty?
|
64
|
+
$stdout.puts
|
65
|
+
$stdout.puts '## Intact tasks'
|
66
|
+
$stdout.puts
|
67
|
+
intacts.each { |x| $stdout.puts x }
|
68
|
+
end
|
69
|
+
unless stales.empty?
|
70
|
+
$stdout.puts
|
71
|
+
$stdout.puts '## Stale tasks'
|
72
|
+
$stdout.puts
|
73
|
+
stales.each { |x| $stdout.puts x }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
subcommand 'volume', 'Volume operations' do
|
79
|
+
|
80
|
+
subcommand ['new', 'create'], 'Create new volume' do
|
81
|
+
parameter 'DIRECTORY', 'Directory to become a Mclone volume'
|
82
|
+
def execute
|
83
|
+
session.format_volume!(directory)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
subcommand 'delete', 'Delete existing volume' do
|
88
|
+
parameter 'VOLUME', 'Volume ID pattern'
|
89
|
+
def execute
|
90
|
+
session.delete_volume!(volume).commit!
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
|
96
|
+
subcommand 'task', 'Task operations' do
|
97
|
+
|
98
|
+
def self.set_task_opts
|
99
|
+
modes = Task::MODES.collect(&:to_s).join(' | ')
|
100
|
+
option ['-m', '--mode'], 'MODE', "Operation mode (#{modes})", default: Task::MODES.first.to_s
|
101
|
+
option ['-i', '--include'], 'PATTERN', 'Include paths pattern'
|
102
|
+
option ['-x', '--exclude'], 'PATTERN', 'Exclude paths pattern'
|
103
|
+
end
|
104
|
+
|
105
|
+
subcommand ['new', 'create'], 'Create new SOURCE -> DESTINATION task' do
|
106
|
+
set_task_opts
|
107
|
+
option ['-d', '--decrypt'], :flag, 'Decrypt source'
|
108
|
+
option ['-e', '--encrypt'], :flag, 'Encrypt destination'
|
109
|
+
option ['-p', '--password'], 'PASSWORD', 'Plain text password'
|
110
|
+
option ['-t', '--token'], 'TOKEN', 'Rclone crypt token (obscured password)'
|
111
|
+
parameter 'SOURCE', 'Source path'
|
112
|
+
parameter 'DESTINATION', 'Destination path'
|
113
|
+
def execute
|
114
|
+
crypter_mode = nil
|
115
|
+
signal_usage_error 'choose either encryption or decryption mode, not both' if decrypt? && encrypt?
|
116
|
+
signal_usage_error 'specify either plain text password or Rclone crypt token, not both' if !password.nil? && !token.nil?
|
117
|
+
crypter_mode = :encrypt if encrypt?
|
118
|
+
crypter_mode = :decrypt if decrypt?
|
119
|
+
session.create_task!(
|
120
|
+
resolve_mode(mode),
|
121
|
+
source,
|
122
|
+
destination,
|
123
|
+
include: include, exclude: exclude, crypter_mode: crypter_mode, crypter_password: password, crypter_token: token
|
124
|
+
).commit!
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
subcommand 'modify', 'Modify existing task' do
|
129
|
+
set_task_opts
|
130
|
+
parameter 'TASK', 'Task ID pattern'
|
131
|
+
def execute
|
132
|
+
session.modify_task!(task, mode: resolve_mode(mode), include: include, exclude: exclude).commit!
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
subcommand 'delete', 'Delete existing task' do
|
137
|
+
parameter 'TASK', 'Task ID pattern'
|
138
|
+
def execute
|
139
|
+
session.delete_task!(task).commit!
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
subcommand 'process', 'Process specified tasks' do
|
144
|
+
parameter '[TASK] ...', 'Task ID pattern(s)', attribute_name: :tasks
|
145
|
+
def execute
|
146
|
+
session.process_tasks!(*tasks)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
rescue Mclone::Error
|
155
|
+
$stderr.puts "ERROR: #{$!.message}"
|
156
|
+
exit(false)
|
157
|
+
end
|
data/lib/mclone.rb
CHANGED
@@ -3,7 +3,9 @@
|
|
3
3
|
|
4
4
|
require 'date'
|
5
5
|
require 'json'
|
6
|
+
require 'open3'
|
6
7
|
require 'fileutils'
|
8
|
+
require 'shellwords'
|
7
9
|
require 'securerandom'
|
8
10
|
|
9
11
|
|
@@ -11,13 +13,11 @@ require 'securerandom'
|
|
11
13
|
module Mclone
|
12
14
|
|
13
15
|
|
14
|
-
VERSION = '0.
|
16
|
+
VERSION = '0.3.0'
|
15
17
|
|
16
18
|
|
17
19
|
#
|
18
|
-
class Error < StandardError
|
19
|
-
|
20
|
-
end
|
20
|
+
class Error < StandardError; end
|
21
21
|
|
22
22
|
#
|
23
23
|
module Refinements
|
@@ -41,6 +41,11 @@ module Mclone
|
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
44
|
+
refine ::String do
|
45
|
+
def escape
|
46
|
+
Mclone.windows? && %r![^\w\-\=\\\/:]!.match?(self) ? %("#{self}") : shellescape
|
47
|
+
end
|
48
|
+
end
|
44
49
|
end
|
45
50
|
|
46
51
|
|
@@ -67,12 +72,31 @@ module Mclone
|
|
67
72
|
@objects.empty?
|
68
73
|
end
|
69
74
|
|
75
|
+
#
|
76
|
+
def size
|
77
|
+
@objects.size
|
78
|
+
end
|
79
|
+
|
70
80
|
def initialize
|
71
81
|
@ids = {} # { id => object }
|
72
82
|
@objects = {} # { object => object }
|
73
83
|
@modified = false
|
74
84
|
end
|
75
85
|
|
86
|
+
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
|
+
|
76
100
|
# Return ID of the object considered equal to the specified obj or nil
|
77
101
|
def id(obj)
|
78
102
|
@objects[obj]&.id
|
@@ -156,6 +180,9 @@ module Mclone
|
|
156
180
|
#
|
157
181
|
attr_reader :include, :exclude
|
158
182
|
|
183
|
+
#
|
184
|
+
attr_reader :crypter_mode
|
185
|
+
|
159
186
|
def hash
|
160
187
|
@hash ||= source_id.hash ^ destination_id.hash ^ source_root.hash ^ destination_root.hash
|
161
188
|
end
|
@@ -172,85 +199,139 @@ module Mclone
|
|
172
199
|
alias == eql?
|
173
200
|
|
174
201
|
#
|
175
|
-
def initialize(source_id, source_root, destination_id, destination_root)
|
202
|
+
def initialize(session, mode, source_id, source_root, destination_id, destination_root, include: nil, exclude: nil, crypter_mode: nil, crypter_token: nil, crypter_password: nil)
|
176
203
|
@touch = false # Indicates that the time stamp should be updated whenever state of self is altered
|
204
|
+
@session = session
|
177
205
|
@id = SecureRandom.hex(4)
|
178
206
|
@source_id = source_id
|
179
207
|
@destination_id = destination_id
|
180
208
|
@source_root = source_root
|
181
209
|
@destination_root = destination_root
|
182
|
-
self.mode =
|
183
|
-
self.include =
|
184
|
-
self.exclude =
|
210
|
+
self.mode = mode
|
211
|
+
self.include = include
|
212
|
+
self.exclude = exclude
|
213
|
+
set_crypter_mode crypter_mode
|
214
|
+
unless crypter_mode.nil?
|
215
|
+
raise(Task::Error, %(either Rclone crypt token or plain text password is expected, not both)) if !crypter_token.nil? && !crypter_password.nil?
|
216
|
+
@assigned_token = register_crypter_token crypter_token
|
217
|
+
@assigned_password = crypter_password
|
218
|
+
end
|
185
219
|
ensure
|
186
220
|
@touch = true
|
187
221
|
touch!
|
188
222
|
end
|
189
223
|
|
224
|
+
CRYPTER_MODES = %i[encrypt decrypt].freeze
|
225
|
+
|
226
|
+
@@crypter_tokens = {}
|
227
|
+
|
228
|
+
private def set_crypter_mode(mode)
|
229
|
+
@crypter_mode = mode.nil? ? nil : (CRYPTER_MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown crypt mode "#{mode}")))
|
230
|
+
end
|
231
|
+
|
232
|
+
private def register_crypter_token(token)
|
233
|
+
unless token.nil?
|
234
|
+
@@crypter_tokens[id] = @@crypter_tokens[id].nil? ? token : raise(Task::Error, %(attempt to re-register token for task "#{id}"))
|
235
|
+
end
|
236
|
+
token
|
237
|
+
end
|
238
|
+
|
239
|
+
# Lazily determine the crypt token from either assigned values or the token repository
|
240
|
+
def crypter_token
|
241
|
+
# Locally assigned token takes precedence over the repository's
|
242
|
+
unless @assigned_token.nil?
|
243
|
+
@@crypter_tokens[id] = @assigned_token unless @@crypter_tokens[id].nil? # Assign repository entry with this local token if not yet assigned
|
244
|
+
@assigned_token
|
245
|
+
else
|
246
|
+
if @@crypter_tokens[id].nil?
|
247
|
+
# If token is neither locally assigned nor in repository, try to construct it from the user-supplied password
|
248
|
+
# If a user-supplied password is omitted, create a new randomly generated password
|
249
|
+
args = [Mclone.rclone, 'obscure', @assigned_password.nil? ? SecureRandom.alphanumeric(16) : @assigned_password]
|
250
|
+
$stdout << args.collect(&:escape).join(' ') << "\n" if @session.verbose?
|
251
|
+
stdout, status = Open3.capture2(*args)
|
252
|
+
raise(Task::Error, %(Rclone execution failure)) unless status.success?
|
253
|
+
@@crypter_tokens[id] = stdout.strip
|
254
|
+
else
|
255
|
+
@@crypter_tokens[id]
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
190
260
|
#
|
191
|
-
MODES = [
|
261
|
+
MODES = %i[update synchronize copy move].freeze
|
192
262
|
|
193
263
|
#
|
194
264
|
def mode=(mode)
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
end
|
265
|
+
@mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
|
266
|
+
touch!
|
267
|
+
mode
|
199
268
|
end
|
200
269
|
|
201
270
|
#
|
202
271
|
def include=(mask)
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
end
|
272
|
+
@include = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
|
273
|
+
touch!
|
274
|
+
mask
|
207
275
|
end
|
208
276
|
|
209
277
|
#
|
210
278
|
def exclude=(mask)
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
end
|
279
|
+
@exclude = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
|
280
|
+
touch!
|
281
|
+
mask
|
215
282
|
end
|
216
283
|
|
217
284
|
#
|
218
|
-
def self.restore(hash)
|
285
|
+
def self.restore(session, hash)
|
219
286
|
obj = allocate
|
220
|
-
obj.send(:from_h, hash)
|
287
|
+
obj.send(:from_h, session, hash)
|
221
288
|
obj
|
222
289
|
end
|
223
290
|
|
224
291
|
#
|
225
|
-
private def from_h(hash)
|
226
|
-
|
292
|
+
private def from_h(session, hash)
|
293
|
+
@session = session
|
227
294
|
@touch = false
|
228
295
|
@id = hash.extract(:task)
|
296
|
+
@mtime = DateTime.parse(hash.extract(:mtime)) rescue DateTime.now # Deleting mtime entry from json can be used to modify data out of mclone
|
297
|
+
@source_id = hash.extract(:source, :volume)
|
298
|
+
@destination_id = hash.extract(:destination, :volume)
|
299
|
+
@source_root = hash.dig(:source, :root)
|
300
|
+
@destination_root = hash.dig(:destination, :root)
|
229
301
|
self.mode = hash.extract(:mode)
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
302
|
+
self.include = hash.dig(:include)
|
303
|
+
self.exclude = hash.dig(:exclude)
|
304
|
+
set_crypter_mode hash.dig(:crypter, :mode)
|
305
|
+
@assigned_token = register_crypter_token(hash.dig(:crypter, :token)) unless crypter_mode.nil?
|
234
306
|
ensure
|
235
307
|
@touch = true
|
236
308
|
end
|
237
309
|
|
238
310
|
#
|
239
|
-
def to_h
|
240
|
-
{
|
241
|
-
mode: mode,
|
242
|
-
include: include,
|
243
|
-
exclude: exclude,
|
311
|
+
def to_h(volume)
|
312
|
+
hash = {
|
244
313
|
task: id,
|
245
|
-
|
246
|
-
|
247
|
-
|
314
|
+
mode: mode,
|
315
|
+
mtime: mtime,
|
316
|
+
source: { volume: source_id },
|
317
|
+
destination: { volume: destination_id }
|
248
318
|
}
|
319
|
+
hash[:source][:root] = source_root unless source_root.nil? || source_root.empty?
|
320
|
+
hash[:destination][:root] = destination_root unless destination_root.nil? || destination_root.empty?
|
321
|
+
hash[:include] = include unless include.nil?
|
322
|
+
hash[:exclude] = exclude unless exclude.nil?
|
323
|
+
unless crypter_mode.nil?
|
324
|
+
crypter = hash[:crypter] = { mode: crypter_mode }
|
325
|
+
# Make sure the token won't get into the encrypted volume's task
|
326
|
+
crypter[:token] = crypter_token if (crypter_mode == :encrypt && source_id == volume.id) || (crypter_mode == :decrypt && destination_id == volume.id)
|
327
|
+
end
|
328
|
+
hash
|
249
329
|
end
|
250
330
|
|
251
331
|
#
|
252
332
|
def touch!
|
253
333
|
@mtime = DateTime.now if @touch
|
334
|
+
self
|
254
335
|
end
|
255
336
|
end
|
256
337
|
|
@@ -270,12 +351,9 @@ module Mclone
|
|
270
351
|
#
|
271
352
|
def resolve(id)
|
272
353
|
case (ids = super).size
|
273
|
-
when 0
|
274
|
-
|
275
|
-
|
276
|
-
ids.first
|
277
|
-
else
|
278
|
-
raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
|
354
|
+
when 0 then raise(Task::Error, %(no task matching "#{id}" pattern found))
|
355
|
+
when 1 then ids.first
|
356
|
+
else raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
|
279
357
|
end
|
280
358
|
end
|
281
359
|
|
@@ -302,8 +380,6 @@ module Mclone
|
|
302
380
|
#
|
303
381
|
attr_reader :file
|
304
382
|
|
305
|
-
#
|
306
|
-
attr_reader :tasks
|
307
383
|
|
308
384
|
#
|
309
385
|
def root
|
@@ -311,31 +387,30 @@ module Mclone
|
|
311
387
|
end
|
312
388
|
|
313
389
|
#
|
314
|
-
def initialize(file)
|
315
|
-
@
|
390
|
+
def initialize(session, file)
|
391
|
+
@loaded_tasks = ObjectSet.new
|
316
392
|
@id = SecureRandom.hex(4)
|
317
|
-
@
|
393
|
+
@session = session
|
394
|
+
@file = file
|
318
395
|
end
|
319
396
|
|
320
397
|
#
|
321
|
-
def self.restore(file)
|
398
|
+
def self.restore(session, file)
|
322
399
|
obj = allocate
|
323
|
-
obj.send(:from_file, file)
|
400
|
+
obj.send(:from_file, session, file)
|
324
401
|
obj
|
325
402
|
end
|
326
403
|
|
327
404
|
#
|
328
|
-
private def from_file(file)
|
329
|
-
initialize(file)
|
405
|
+
private def from_file(session, file)
|
330
406
|
hash = JSON.parse(IO.read(file), symbolize_names: true)
|
407
|
+
@loaded_tasks = ObjectSet.new
|
408
|
+
@id = hash.extract(:volume)
|
409
|
+
@session = session
|
410
|
+
@file = file
|
331
411
|
raise(Volume::Error, %(unsupported Mclone volume format version "#{version}")) unless hash.extract(:mclone) == VERSION
|
332
|
-
@
|
333
|
-
|
334
|
-
end
|
335
|
-
|
336
|
-
#
|
337
|
-
def dirty?
|
338
|
-
tasks.modified?
|
412
|
+
hash.dig(:tasks)&.each { |h| session.tasks << (@loaded_tasks << Task.restore(@session, h)) }
|
413
|
+
self
|
339
414
|
end
|
340
415
|
|
341
416
|
#
|
@@ -348,23 +423,43 @@ module Mclone
|
|
348
423
|
equal?(other) || id == other.id
|
349
424
|
end
|
350
425
|
|
426
|
+
#
|
427
|
+
def modified?
|
428
|
+
# Comparison against the original loaded tasks set allows to account for task removals
|
429
|
+
(ts = tasks).modified? || ts != @loaded_tasks
|
430
|
+
end
|
431
|
+
|
351
432
|
#
|
352
433
|
def commit!(force = false)
|
353
|
-
if force ||
|
354
|
-
|
355
|
-
|
356
|
-
|
434
|
+
if force || @session.force? || modified?
|
435
|
+
# As a safeguard against malformed volume files generation, first write to a new file
|
436
|
+
# and rename it to a real volume file only in case of normal turn of events
|
437
|
+
_file = "#{file}~"
|
438
|
+
begin
|
439
|
+
open(_file, 'w') do |stream|
|
440
|
+
stream << JSON.pretty_generate(to_h)
|
441
|
+
tasks.commit!
|
442
|
+
end
|
443
|
+
FileUtils.mv(_file, file, force: true)
|
444
|
+
ensure
|
445
|
+
FileUtils.rm_f(_file)
|
357
446
|
end
|
358
447
|
end
|
448
|
+
self
|
449
|
+
end
|
450
|
+
|
451
|
+
#
|
452
|
+
def tasks
|
453
|
+
TaskSet.new(self).merge!(@session.tasks)
|
359
454
|
end
|
360
455
|
|
361
456
|
#
|
362
457
|
def to_h
|
363
|
-
{mclone: VERSION, volume: id, tasks: tasks.collect(
|
458
|
+
{ mclone: VERSION, volume: id, tasks: tasks.collect { |task| task.to_h(self) } }
|
364
459
|
end
|
365
460
|
|
366
461
|
# Volume-bound set of tasks belonging to the specific volume
|
367
|
-
class
|
462
|
+
class TaskSet < Mclone::TaskSet
|
368
463
|
|
369
464
|
def initialize(volume)
|
370
465
|
@volume = volume
|
@@ -388,12 +483,9 @@ module Mclone
|
|
388
483
|
#
|
389
484
|
def resolve(id)
|
390
485
|
case (ids = super).size
|
391
|
-
when 0
|
392
|
-
|
393
|
-
|
394
|
-
ids.first
|
395
|
-
else
|
396
|
-
raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
|
486
|
+
when 0 then raise(Volume::Error, %(no volume matching "#{id}" pattern found))
|
487
|
+
when 1 then ids.first
|
488
|
+
else raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
|
397
489
|
end
|
398
490
|
end
|
399
491
|
|
@@ -429,31 +521,33 @@ module Mclone
|
|
429
521
|
#
|
430
522
|
attr_writer :simulate, :verbose, :force
|
431
523
|
|
524
|
+
#
|
525
|
+
attr_reader :tasks
|
526
|
+
|
432
527
|
#
|
433
528
|
def initialize
|
434
529
|
@volumes = VolumeSet.new
|
530
|
+
@tasks = SessionTaskSet.new(self)
|
435
531
|
end
|
436
532
|
|
437
533
|
#
|
438
534
|
def format_volume!(dir)
|
439
535
|
mclone = File.join(dir, Volume::FILE)
|
440
536
|
raise(Session::Error, %(refuse to overwrite existing Mclone volume file "#{mclone}")) if File.exist?(mclone) && !force?
|
441
|
-
volume = Volume.new(mclone)
|
442
|
-
|
443
|
-
volume.commit!(true) unless simulate? # Force creating a new (empty) volume
|
537
|
+
volumes << (volume = Volume.new(self, mclone))
|
538
|
+
volume.commit!(true) unless simulate? # Force creation of a new (empty) volume
|
444
539
|
self
|
445
540
|
end
|
446
541
|
|
447
542
|
#
|
448
543
|
def restore_volume!(dir)
|
449
|
-
|
450
|
-
@volumes << volume
|
544
|
+
volumes << Volume.restore(self, File.join(dir, Volume::FILE))
|
451
545
|
self
|
452
546
|
end
|
453
547
|
|
454
548
|
#
|
455
549
|
def restore_volumes!
|
456
|
-
(Mclone.environment_mounts + Mclone.system_mounts).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
|
550
|
+
(Mclone.environment_mounts + Mclone.system_mounts + [ENV['HOME']]).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
|
457
551
|
self
|
458
552
|
end
|
459
553
|
|
@@ -467,84 +561,83 @@ module Mclone
|
|
467
561
|
end
|
468
562
|
|
469
563
|
#
|
470
|
-
def create_task!(source, destination,
|
471
|
-
task = Task.new(*locate(source), *locate(destination))
|
472
|
-
|
473
|
-
task.
|
474
|
-
|
475
|
-
volumes.each do |volume|
|
476
|
-
t = volume.tasks[task]
|
477
|
-
raise(Session::Error, %(refuse to overwrite existing task "#{t.id}")) unless t.nil? || force?
|
478
|
-
volume.tasks << task # It is a volume's responsibility to collect appropriate tasks, see Volume::TaskSet#<<
|
479
|
-
end
|
564
|
+
def create_task!(mode, source, destination, **kws)
|
565
|
+
task = Task.new(self, mode, *locate(source), *locate(destination), **kws)
|
566
|
+
_task = tasks[task]
|
567
|
+
raise(Session::Error, %(refuse to overwrite existing task "#{_task.id}")) unless _task.nil? || force?
|
568
|
+
tasks << task
|
480
569
|
self
|
481
570
|
end
|
482
571
|
|
483
572
|
#
|
484
|
-
def modify_task!(id, mode
|
573
|
+
def modify_task!(id, mode: nil, include: nil, exclude: nil)
|
485
574
|
ts = tasks
|
486
575
|
task = ts.task(ts.resolve(id)).clone
|
487
|
-
task.mode = mode
|
488
|
-
task.include = include
|
489
|
-
task.exclude = exclude
|
490
|
-
|
576
|
+
task.mode = mode unless mode.nil?
|
577
|
+
task.include = include unless include.nil?
|
578
|
+
task.exclude = exclude unless exclude.nil?
|
579
|
+
tasks << task
|
491
580
|
self
|
492
581
|
end
|
493
582
|
|
494
583
|
#
|
495
584
|
def delete_task!(id)
|
496
|
-
|
497
|
-
task = ts.task(ts.resolve(id))
|
498
|
-
volumes.each { |volume| volume.tasks >> task }
|
585
|
+
tasks >> tasks.task(tasks.resolve(id))
|
499
586
|
self
|
500
587
|
end
|
501
588
|
|
502
589
|
#
|
503
590
|
def process_tasks!(*ids)
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
ids.collect { |id|
|
508
|
-
|
591
|
+
failed = false
|
592
|
+
intacts = intact_tasks
|
593
|
+
ids = intacts.collect(&:id) if ids.empty?
|
594
|
+
ids.collect { |id| intacts.task(intacts.resolve(id)) }.each do |task|
|
595
|
+
source_path = File.join(volumes.volume(task.source_id).root, task.source_root.nil? || task.source_root.empty? ? '' : task.source_root)
|
596
|
+
destination_path = File.join(volumes.volume(task.destination_id).root, task.destination_root.nil? || task.destination_root.empty? ? '' : task.destination_root)
|
597
|
+
args = [Mclone.rclone]
|
509
598
|
opts = [
|
599
|
+
'--config', Mclone.windows? ? 'NUL' : '/dev/null',
|
510
600
|
simulate? ? '--dry-run' : nil,
|
511
|
-
verbose? ? '--verbose' : nil
|
601
|
+
verbose? ? '--verbose' : nil,
|
602
|
+
verbose? ? '--progress' : nil
|
512
603
|
].compact
|
604
|
+
opts.append('--crypt-password', task.crypter_token) unless task.crypter_mode.nil?
|
605
|
+
case task.crypter_mode
|
606
|
+
when :encrypt then opts.append('--crypt-remote', destination_path)
|
607
|
+
when :decrypt then opts.append('--crypt-remote', source_path)
|
608
|
+
end
|
513
609
|
case task.mode
|
514
|
-
when :update
|
515
|
-
|
516
|
-
|
517
|
-
when :
|
518
|
-
args << 'sync'
|
519
|
-
when :copy
|
520
|
-
args << 'copy'
|
521
|
-
when :move
|
522
|
-
args << 'move'
|
610
|
+
when :update then args.push('copy', '--update')
|
611
|
+
when :synchronize then args << 'sync'
|
612
|
+
when :copy then args << 'copy'
|
613
|
+
when :move then args << 'move'
|
523
614
|
end
|
524
615
|
opts.append('--filter', "- /#{Volume::FILE}")
|
525
616
|
opts.append('--filter', "- #{task.exclude}") unless task.exclude.nil? || task.exclude.empty?
|
526
617
|
opts.append('--filter', "+ #{task.include}") unless task.include.nil? || task.include.empty?
|
527
618
|
args.concat(opts)
|
528
|
-
|
619
|
+
case task.crypter_mode
|
620
|
+
when nil then args.append(source_path, destination_path)
|
621
|
+
when :encrypt then args.append(source_path, ':crypt:')
|
622
|
+
when :decrypt then args.append(':crypt:', destination_path)
|
623
|
+
end
|
624
|
+
$stdout << args.collect(&:escape).join(' ') << "\n" if verbose?
|
529
625
|
case system(*args)
|
530
|
-
when nil
|
531
|
-
|
626
|
+
when nil
|
627
|
+
$stderr << %(failed to execute "#{args.first}") << "\n" if verbose?
|
628
|
+
failed = true
|
629
|
+
when false
|
630
|
+
$stderr << %(Rclone exited with status #{$?.to_i}) << "\n" if verbose?
|
631
|
+
failed = true
|
532
632
|
end
|
533
633
|
end
|
534
|
-
|
535
|
-
|
536
|
-
# Collect all tasks from all loaded volumes
|
537
|
-
def tasks
|
538
|
-
tasks = SessionTaskSet.new(self)
|
539
|
-
volumes.each { |volume| tasks.merge!(volume.tasks) }
|
540
|
-
tasks
|
634
|
+
raise(Session::Error, "Rclone execution failure(s)") if failed
|
635
|
+
self
|
541
636
|
end
|
542
637
|
|
543
638
|
# Collect all tasks from all loaded volumes which are ready to be executed
|
544
639
|
def intact_tasks
|
545
|
-
|
546
|
-
volumes.each { |volume| tasks.merge!(volume.tasks) }
|
547
|
-
tasks
|
640
|
+
IntactTaskSet.new(self).merge!(tasks)
|
548
641
|
end
|
549
642
|
|
550
643
|
#
|
@@ -587,63 +680,74 @@ module Mclone
|
|
587
680
|
|
588
681
|
end
|
589
682
|
|
683
|
+
#
|
684
|
+
def self.rclone
|
685
|
+
@@rclone ||= (rclone = ENV['RCLONE']).nil? ? 'rclone' : rclone
|
686
|
+
end
|
687
|
+
|
590
688
|
# Return true if run in the real Windows environment (e.g. not in real *NIX or various emulation layers such as MSYS, Cygwin etc.)
|
591
689
|
def self.windows?
|
592
690
|
@@windows ||= /^(mingw)/.match?(RbConfig::CONFIG['target_os']) # RubyInstaller's MRI, other MinGW-build MRI
|
593
691
|
end
|
594
692
|
|
595
693
|
# Match OS-specific system mount points (/dev /proc etc.) which normally should be omitted when scanning for Mclone voulmes
|
596
|
-
UNIX_SYSTEM_MOUNTS = %r!^/(dev|sys|proc
|
694
|
+
UNIX_SYSTEM_MOUNTS = %r!^/(dev|sys|proc)!
|
597
695
|
|
598
696
|
# TODO handle Windows variants
|
599
697
|
# Specify OS-specific path name list separator (such as in the $PATH environment variable)
|
600
698
|
PATH_LIST_SEPARATOR = windows? ? ';' : ':'
|
601
699
|
|
602
700
|
# Return list of live user-provided mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
|
603
|
-
# Look for the $
|
701
|
+
# Look for the $MCLONE_PATH environment variable
|
604
702
|
def self.environment_mounts
|
605
703
|
ENV['MCLONE_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
|
606
704
|
end
|
607
|
-
|
608
705
|
# Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
|
609
|
-
|
610
|
-
|
611
|
-
# Linux OS
|
706
|
+
if RUBY_PLATFORM =~ /java/
|
707
|
+
require 'java'
|
612
708
|
def self.system_mounts
|
613
|
-
|
614
|
-
IO.readlines('/proc/self/mountstats').collect do |line|
|
615
|
-
mount = line.split[4]
|
616
|
-
UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
|
617
|
-
end.compact
|
618
|
-
end
|
619
|
-
# TODO handle Windows variants
|
620
|
-
when /^mingw/ # RubyInstaller's MRI
|
621
|
-
module Kernel32
|
622
|
-
require 'fiddle'
|
623
|
-
require 'fiddle/types'
|
624
|
-
require 'fiddle/import'
|
625
|
-
extend Fiddle::Importer
|
626
|
-
dlload('kernel32')
|
627
|
-
include Fiddle::Win32Types
|
628
|
-
extern 'DWORD WINAPI GetLogicalDrives()'
|
629
|
-
end
|
630
|
-
def self.system_mounts
|
631
|
-
mounts = []
|
632
|
-
mask = Kernel32.GetLogicalDrives
|
633
|
-
('A'..'Z').each do |x|
|
634
|
-
mounts << "#{x}:" if mask & 1 == 1
|
635
|
-
mask >>= 1
|
636
|
-
end
|
637
|
-
mounts
|
709
|
+
java.nio.file.FileSystems.getDefault.getFileStores.collect {|x| /^(.*)\s+\(.*\)$/.match(x.to_s)[1]}
|
638
710
|
end
|
639
711
|
else
|
640
|
-
|
641
|
-
|
642
|
-
#
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
712
|
+
case RbConfig::CONFIG['target_os']
|
713
|
+
when 'linux'
|
714
|
+
# Linux OS
|
715
|
+
def self.system_mounts
|
716
|
+
# Query /proc for currently mounted file systems
|
717
|
+
IO.readlines('/proc/self/mountstats').collect do |line|
|
718
|
+
mount = line.split[4]
|
719
|
+
UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
|
720
|
+
end.compact
|
721
|
+
end
|
722
|
+
# TODO handle Windows variants
|
723
|
+
when /^mingw/ # RubyInstaller's MRI
|
724
|
+
module Kernel32
|
725
|
+
require 'fiddle'
|
726
|
+
require 'fiddle/types'
|
727
|
+
require 'fiddle/import'
|
728
|
+
extend Fiddle::Importer
|
729
|
+
dlload('kernel32')
|
730
|
+
include Fiddle::Win32Types
|
731
|
+
extern 'DWORD WINAPI GetLogicalDrives()'
|
732
|
+
end
|
733
|
+
def self.system_mounts
|
734
|
+
mounts = []
|
735
|
+
mask = Kernel32.GetLogicalDrives
|
736
|
+
('A'..'Z').each do |x|
|
737
|
+
mounts << "#{x}:" if mask & 1 == 1
|
738
|
+
mask >>= 1
|
739
|
+
end
|
740
|
+
mounts
|
741
|
+
end
|
742
|
+
else
|
743
|
+
# Generic *NIX-like OS, including Cygwin & MSYS(2)
|
744
|
+
def self.system_mounts
|
745
|
+
# Use $(mount) system utility to obtain currently mounted file systems
|
746
|
+
%x(mount).split("\n").collect do |line|
|
747
|
+
mount = line.split[2]
|
748
|
+
UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
|
749
|
+
end.compact
|
750
|
+
end
|
647
751
|
end
|
648
752
|
end
|
649
753
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mclone
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Oleg A. Khlybov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-08-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: clamp
|
@@ -38,6 +38,7 @@ files:
|
|
38
38
|
- README.md
|
39
39
|
- bin/mclone
|
40
40
|
- lib/mclone.rb
|
41
|
+
- lib/mclone/cli.rb
|
41
42
|
homepage: https://github.com/okhlybov/mclone
|
42
43
|
licenses:
|
43
44
|
- BSD-3-Clause
|
@@ -57,7 +58,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
58
|
- !ruby/object:Gem::Version
|
58
59
|
version: '0'
|
59
60
|
requirements: []
|
60
|
-
rubygems_version: 3.
|
61
|
+
rubygems_version: 3.3.7
|
61
62
|
signing_key:
|
62
63
|
specification_version: 4
|
63
64
|
summary: Rclone frontend for offline synchronization
|