mclone 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +12 -0
  3. data/README.md +56 -16
  4. data/bin/mclone +1 -142
  5. data/lib/mclone/cli.rb +157 -0
  6. data/lib/mclone.rb +265 -161
  7. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c89cb8e633183fd633ff44b2e87fdc73c5cd348bb377e61f77d12cde4d389e61
4
- data.tar.gz: 1844a2921e78e5cc2057b0aa906494e978883ffda60a487f1881743fbe5598cd
3
+ metadata.gz: 1802e6cb7da3dcfd5bf15e49e91308d6d646365d00a8b1081a551b832928b773
4
+ data.tar.gz: 68e5dff91daa8803072069a47ac2da0a62ba25d577ee6cb4f534eb34f99e0c79
5
5
  SHA512:
6
- metadata.gz: dec529f91301c96fd64b839aa4fdcd4cb733f076d8e56ab506ab86ebe0ec075821623d5e5bf8e91a871d72498b2a77db70b5328837764f9d95987b2a24a62150
7
- data.tar.gz: fac7f8a99b14bdacbe1048d7ba5e7d325863677bf3149a6f2c2a23665996ec75790caed9b51dde2260300c4afd838795c05134284b3f738c94c268d601108c21
6
+ metadata.gz: 1b6b121680b408171f9b4aa8c0a7dabcc9c49ace1ba14dc8476fa4456c46cf6230e0efc27b88ae79a79393163a698c1a25248edd92a09adf732e95d0979c5ac7
7
+ data.tar.gz: 93a43412ffaa68679f980e68d4e40c5ee87d3f144e87b138b28cfee1fedfa611e22e5ca02f14d6eee3ba5c5603bf02c3a18357ade1ef12d45d45497ed7c95287
data/CHANGES.md CHANGED
@@ -1,3 +1,15 @@
1
+ # 0.3.0 (2022-08-31)
2
+
3
+ * JRuby platform support
4
+
5
+ # 0.2.1 (2021-08-09)
6
+
7
+ * Linux-related bugfixes.
8
+
9
+ # 0.2.0 (2021-08-08)
10
+
11
+ * Support for encryption.
12
+
1
13
  # 0.1.1 (2021-06-24)
2
14
 
3
15
  * Switch from FFI to Fiddle.
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, exteral HDD or SSD - whatever) which
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 device can be
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 the command line for execution in this case
28
- becomes tedious and error prone where the possible cost of error is a backup corruption due to wrong paths or misspelled flags.
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 the command line options and detecting
32
- the proper source and destination locations wherever they are.
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 create [OPTIONS] SOURCE DESTINATION
317
+ mclone task new [OPTIONS] SOURCE DESTINATION
283
318
 
284
319
  Parameters:
285
- SOURCE Source path
286
- DESTINATION Destination path
320
+ SOURCE Source path
321
+ DESTINATION Destination path
287
322
 
288
323
  Options:
289
- -m, --mode MODE Operation mode (update | synchronize | copy | move) (default: "update")
290
- -i, --include PATTERN Include paths pattern (default: "**")
291
- -x, --exclude PATTERN Exclude paths pattern
292
- -f, --force Insist on potentially dangerous activities (default: false)
293
- -n, --dry-run Simulation mode with no on-disk modifications (default: false)
294
- -v, --verbose Verbose operation (default: false)
295
- -h, --help print help
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
- # frozen_string_literal: true
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.1.1'
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 = :update
183
- self.include = '**'
184
- self.exclude = ''
210
+ self.mode = mode
211
+ self.include = include
212
+ self.exclude = exclude
213
+ set_crypter_mode crypter_mode
214
+ unless crypter_mode.nil?
215
+ raise(Task::Error, %(either Rclone crypt token or plain text password is expected, not both)) if !crypter_token.nil? && !crypter_password.nil?
216
+ @assigned_token = register_crypter_token crypter_token
217
+ @assigned_password = crypter_password
218
+ end
185
219
  ensure
186
220
  @touch = true
187
221
  touch!
188
222
  end
189
223
 
224
+ CRYPTER_MODES = %i[encrypt decrypt].freeze
225
+
226
+ @@crypter_tokens = {}
227
+
228
+ private def set_crypter_mode(mode)
229
+ @crypter_mode = mode.nil? ? nil : (CRYPTER_MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown crypt mode "#{mode}")))
230
+ end
231
+
232
+ private def register_crypter_token(token)
233
+ unless token.nil?
234
+ @@crypter_tokens[id] = @@crypter_tokens[id].nil? ? token : raise(Task::Error, %(attempt to re-register token for task "#{id}"))
235
+ end
236
+ token
237
+ end
238
+
239
+ # Lazily determine the crypt token from either assigned values or the token repository
240
+ def crypter_token
241
+ # Locally assigned token takes precedence over the repository's
242
+ unless @assigned_token.nil?
243
+ @@crypter_tokens[id] = @assigned_token unless @@crypter_tokens[id].nil? # Assign repository entry with this local token if not yet assigned
244
+ @assigned_token
245
+ else
246
+ if @@crypter_tokens[id].nil?
247
+ # If token is neither locally assigned nor in repository, try to construct it from the user-supplied password
248
+ # If a user-supplied password is omitted, create a new randomly generated password
249
+ args = [Mclone.rclone, 'obscure', @assigned_password.nil? ? SecureRandom.alphanumeric(16) : @assigned_password]
250
+ $stdout << args.collect(&:escape).join(' ') << "\n" if @session.verbose?
251
+ stdout, status = Open3.capture2(*args)
252
+ raise(Task::Error, %(Rclone execution failure)) unless status.success?
253
+ @@crypter_tokens[id] = stdout.strip
254
+ else
255
+ @@crypter_tokens[id]
256
+ end
257
+ end
258
+ end
259
+
190
260
  #
191
- MODES = [:update, :synchronize, :copy, :move].freeze
261
+ MODES = %i[update synchronize copy move].freeze
192
262
 
193
263
  #
194
264
  def mode=(mode)
195
- unless mode.nil?
196
- @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
197
- touch!
198
- end
265
+ @mode = MODES.include?(mode = mode.intern) ? mode : raise(Task::Error, %(unknown mode "#{mode}"))
266
+ touch!
267
+ mode
199
268
  end
200
269
 
201
270
  #
202
271
  def include=(mask)
203
- unless mask.nil?
204
- @include = mask # TODO extensive verification
205
- touch!
206
- end
272
+ @include = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
273
+ touch!
274
+ mask
207
275
  end
208
276
 
209
277
  #
210
278
  def exclude=(mask)
211
- unless mask.nil?
212
- @exclude = mask # TODO extensive verification
213
- touch!
214
- end
279
+ @exclude = mask.nil? || mask == '-' ? nil : mask # TODO: verify mask
280
+ touch!
281
+ mask
215
282
  end
216
283
 
217
284
  #
218
- def self.restore(hash)
285
+ def self.restore(session, hash)
219
286
  obj = allocate
220
- obj.send(:from_h, hash)
287
+ obj.send(:from_h, session, hash)
221
288
  obj
222
289
  end
223
290
 
224
291
  #
225
- private def from_h(hash)
226
- initialize(hash.extract(:source, :volume), hash.extract(:source, :root), hash.extract(:destination, :volume), hash.extract(:destination, :root))
292
+ private def from_h(session, hash)
293
+ @session = session
227
294
  @touch = false
228
295
  @id = hash.extract(:task)
296
+ @mtime = DateTime.parse(hash.extract(:mtime)) rescue DateTime.now # Deleting mtime entry from json can be used to modify data out of mclone
297
+ @source_id = hash.extract(:source, :volume)
298
+ @destination_id = hash.extract(:destination, :volume)
299
+ @source_root = hash.dig(:source, :root)
300
+ @destination_root = hash.dig(:destination, :root)
229
301
  self.mode = hash.extract(:mode)
230
- # Deleting the mtime key from json
231
- @mtime = DateTime.parse(hash.extract(:mtime)) rescue @mtime
232
- self.include = hash.extract(:include) rescue nil
233
- self.exclude = hash.extract(:exclude) rescue nil
302
+ self.include = hash.dig(:include)
303
+ self.exclude = hash.dig(:exclude)
304
+ set_crypter_mode hash.dig(:crypter, :mode)
305
+ @assigned_token = register_crypter_token(hash.dig(:crypter, :token)) unless crypter_mode.nil?
234
306
  ensure
235
307
  @touch = true
236
308
  end
237
309
 
238
310
  #
239
- def to_h
240
- {
241
- mode: mode,
242
- include: include,
243
- exclude: exclude,
311
+ def to_h(volume)
312
+ hash = {
244
313
  task: id,
245
- source: {volume: source_id, root: source_root},
246
- destination: {volume: destination_id, root: destination_root},
247
- mtime: mtime
314
+ mode: mode,
315
+ mtime: mtime,
316
+ source: { volume: source_id },
317
+ destination: { volume: destination_id }
248
318
  }
319
+ hash[:source][:root] = source_root unless source_root.nil? || source_root.empty?
320
+ hash[:destination][:root] = destination_root unless destination_root.nil? || destination_root.empty?
321
+ hash[:include] = include unless include.nil?
322
+ hash[:exclude] = exclude unless exclude.nil?
323
+ unless crypter_mode.nil?
324
+ crypter = hash[:crypter] = { mode: crypter_mode }
325
+ # Make sure the token won't get into the encrypted volume's task
326
+ crypter[:token] = crypter_token if (crypter_mode == :encrypt && source_id == volume.id) || (crypter_mode == :decrypt && destination_id == volume.id)
327
+ end
328
+ hash
249
329
  end
250
330
 
251
331
  #
252
332
  def touch!
253
333
  @mtime = DateTime.now if @touch
334
+ self
254
335
  end
255
336
  end
256
337
 
@@ -270,12 +351,9 @@ module Mclone
270
351
  #
271
352
  def resolve(id)
272
353
  case (ids = super).size
273
- when 0
274
- raise(Task::Error, %(no task matching "#{id}" pattern found))
275
- when 1
276
- ids.first
277
- else
278
- raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
354
+ when 0 then raise(Task::Error, %(no task matching "#{id}" pattern found))
355
+ when 1 then ids.first
356
+ else raise(Task::Error, %(ambiguous "#{id}" pattern: two or more tasks match))
279
357
  end
280
358
  end
281
359
 
@@ -302,8 +380,6 @@ module Mclone
302
380
  #
303
381
  attr_reader :file
304
382
 
305
- #
306
- attr_reader :tasks
307
383
 
308
384
  #
309
385
  def root
@@ -311,31 +387,30 @@ module Mclone
311
387
  end
312
388
 
313
389
  #
314
- def initialize(file)
315
- @file = file
390
+ def initialize(session, file)
391
+ @loaded_tasks = ObjectSet.new
316
392
  @id = SecureRandom.hex(4)
317
- @tasks = VolumeTaskSet.new(self)
393
+ @session = session
394
+ @file = file
318
395
  end
319
396
 
320
397
  #
321
- def self.restore(file)
398
+ def self.restore(session, file)
322
399
  obj = allocate
323
- obj.send(:from_file, file)
400
+ obj.send(:from_file, session, file)
324
401
  obj
325
402
  end
326
403
 
327
404
  #
328
- private def from_file(file)
329
- initialize(file)
405
+ private def from_file(session, file)
330
406
  hash = JSON.parse(IO.read(file), symbolize_names: true)
407
+ @loaded_tasks = ObjectSet.new
408
+ @id = hash.extract(:volume)
409
+ @session = session
410
+ @file = file
331
411
  raise(Volume::Error, %(unsupported Mclone volume format version "#{version}")) unless hash.extract(:mclone) == VERSION
332
- @id = hash.extract(:volume).to_s
333
- hash.extract(:tasks).each { |t| @tasks << Task.restore(t) }
334
- end
335
-
336
- #
337
- def dirty?
338
- tasks.modified?
412
+ hash.dig(:tasks)&.each { |h| session.tasks << (@loaded_tasks << Task.restore(@session, h)) }
413
+ self
339
414
  end
340
415
 
341
416
  #
@@ -348,23 +423,43 @@ module Mclone
348
423
  equal?(other) || id == other.id
349
424
  end
350
425
 
426
+ #
427
+ def modified?
428
+ # Comparison against the original loaded tasks set allows to account for task removals
429
+ (ts = tasks).modified? || ts != @loaded_tasks
430
+ end
431
+
351
432
  #
352
433
  def commit!(force = false)
353
- if force || dirty?
354
- open(file, 'w') do |stream|
355
- stream << JSON.pretty_generate(to_h)
356
- tasks.commit!
434
+ if force || @session.force? || modified?
435
+ # As a safeguard against malformed volume files generation, first write to a new file
436
+ # and rename it to a real volume file only in case of normal turn of events
437
+ _file = "#{file}~"
438
+ begin
439
+ open(_file, 'w') do |stream|
440
+ stream << JSON.pretty_generate(to_h)
441
+ tasks.commit!
442
+ end
443
+ FileUtils.mv(_file, file, force: true)
444
+ ensure
445
+ FileUtils.rm_f(_file)
357
446
  end
358
447
  end
448
+ self
449
+ end
450
+
451
+ #
452
+ def tasks
453
+ TaskSet.new(self).merge!(@session.tasks)
359
454
  end
360
455
 
361
456
  #
362
457
  def to_h
363
- {mclone: VERSION, volume: id, tasks: tasks.collect(&:to_h)}
458
+ { mclone: VERSION, volume: id, tasks: tasks.collect { |task| task.to_h(self) } }
364
459
  end
365
460
 
366
461
  # Volume-bound set of tasks belonging to the specific volume
367
- class VolumeTaskSet < Mclone::TaskSet
462
+ class TaskSet < Mclone::TaskSet
368
463
 
369
464
  def initialize(volume)
370
465
  @volume = volume
@@ -388,12 +483,9 @@ module Mclone
388
483
  #
389
484
  def resolve(id)
390
485
  case (ids = super).size
391
- when 0
392
- raise(Volume::Error, %(no volume matching "#{id}" pattern found))
393
- when 1
394
- ids.first
395
- else
396
- raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
486
+ when 0 then raise(Volume::Error, %(no volume matching "#{id}" pattern found))
487
+ when 1 then ids.first
488
+ else raise(Volume::Error, %(ambiguous "#{id}" pattern: two or more volumes match))
397
489
  end
398
490
  end
399
491
 
@@ -429,31 +521,33 @@ module Mclone
429
521
  #
430
522
  attr_writer :simulate, :verbose, :force
431
523
 
524
+ #
525
+ attr_reader :tasks
526
+
432
527
  #
433
528
  def initialize
434
529
  @volumes = VolumeSet.new
530
+ @tasks = SessionTaskSet.new(self)
435
531
  end
436
532
 
437
533
  #
438
534
  def format_volume!(dir)
439
535
  mclone = File.join(dir, Volume::FILE)
440
536
  raise(Session::Error, %(refuse to overwrite existing Mclone volume file "#{mclone}")) if File.exist?(mclone) && !force?
441
- volume = Volume.new(mclone)
442
- @volumes << volume
443
- volume.commit!(true) unless simulate? # Force creating a new (empty) volume
537
+ volumes << (volume = Volume.new(self, mclone))
538
+ volume.commit!(true) unless simulate? # Force creation of a new (empty) volume
444
539
  self
445
540
  end
446
541
 
447
542
  #
448
543
  def restore_volume!(dir)
449
- volume = Volume.restore(File.join(dir, Volume::FILE))
450
- @volumes << volume
544
+ volumes << Volume.restore(self, File.join(dir, Volume::FILE))
451
545
  self
452
546
  end
453
547
 
454
548
  #
455
549
  def restore_volumes!
456
- (Mclone.environment_mounts + Mclone.system_mounts).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
550
+ (Mclone.environment_mounts + Mclone.system_mounts + [ENV['HOME']]).each { |dir| restore_volume!(dir) rescue Errno::ENOENT }
457
551
  self
458
552
  end
459
553
 
@@ -467,84 +561,83 @@ module Mclone
467
561
  end
468
562
 
469
563
  #
470
- def create_task!(source, destination, mode: :update, include: '**', exclude: '')
471
- task = Task.new(*locate(source), *locate(destination))
472
- task.mode = mode
473
- task.include = include
474
- task.exclude = exclude
475
- volumes.each do |volume|
476
- t = volume.tasks[task]
477
- raise(Session::Error, %(refuse to overwrite existing task "#{t.id}")) unless t.nil? || force?
478
- volume.tasks << task # It is a volume's responsibility to collect appropriate tasks, see Volume::TaskSet#<<
479
- end
564
+ def create_task!(mode, source, destination, **kws)
565
+ task = Task.new(self, mode, *locate(source), *locate(destination), **kws)
566
+ _task = tasks[task]
567
+ raise(Session::Error, %(refuse to overwrite existing task "#{_task.id}")) unless _task.nil? || force?
568
+ tasks << task
480
569
  self
481
570
  end
482
571
 
483
572
  #
484
- def modify_task!(id, mode:, include:, exclude:)
573
+ def modify_task!(id, mode: nil, include: nil, exclude: nil)
485
574
  ts = tasks
486
575
  task = ts.task(ts.resolve(id)).clone
487
- task.mode = mode
488
- task.include = include
489
- task.exclude = exclude
490
- volumes.each { |volume| volume.tasks << task }
576
+ task.mode = mode unless mode.nil?
577
+ task.include = include unless include.nil?
578
+ task.exclude = exclude unless exclude.nil?
579
+ tasks << task
491
580
  self
492
581
  end
493
582
 
494
583
  #
495
584
  def delete_task!(id)
496
- ts = tasks
497
- task = ts.task(ts.resolve(id))
498
- volumes.each { |volume| volume.tasks >> task }
585
+ tasks >> tasks.task(tasks.resolve(id))
499
586
  self
500
587
  end
501
588
 
502
589
  #
503
590
  def process_tasks!(*ids)
504
- ts = intact_tasks
505
- ids = ts.collect(&:id) if ids.empty?
506
- rclone = 'rclone' if (rclone = ENV['RCLONE']).nil?
507
- ids.collect { |id| ts.task(ts.resolve(id)) }.each do |task|
508
- args = [rclone]
591
+ failed = false
592
+ intacts = intact_tasks
593
+ ids = intacts.collect(&:id) if ids.empty?
594
+ ids.collect { |id| intacts.task(intacts.resolve(id)) }.each do |task|
595
+ source_path = File.join(volumes.volume(task.source_id).root, task.source_root.nil? || task.source_root.empty? ? '' : task.source_root)
596
+ destination_path = File.join(volumes.volume(task.destination_id).root, task.destination_root.nil? || task.destination_root.empty? ? '' : task.destination_root)
597
+ args = [Mclone.rclone]
509
598
  opts = [
599
+ '--config', Mclone.windows? ? 'NUL' : '/dev/null',
510
600
  simulate? ? '--dry-run' : nil,
511
- verbose? ? '--verbose' : nil
601
+ verbose? ? '--verbose' : nil,
602
+ verbose? ? '--progress' : nil
512
603
  ].compact
604
+ opts.append('--crypt-password', task.crypter_token) unless task.crypter_mode.nil?
605
+ case task.crypter_mode
606
+ when :encrypt then opts.append('--crypt-remote', destination_path)
607
+ when :decrypt then opts.append('--crypt-remote', source_path)
608
+ end
513
609
  case task.mode
514
- when :update
515
- args << 'copy'
516
- opts << '--update'
517
- when :synchronize
518
- args << 'sync'
519
- when :copy
520
- args << 'copy'
521
- when :move
522
- args << 'move'
610
+ when :update then args.push('copy', '--update')
611
+ when :synchronize then args << 'sync'
612
+ when :copy then args << 'copy'
613
+ when :move then args << 'move'
523
614
  end
524
615
  opts.append('--filter', "- /#{Volume::FILE}")
525
616
  opts.append('--filter', "- #{task.exclude}") unless task.exclude.nil? || task.exclude.empty?
526
617
  opts.append('--filter', "+ #{task.include}") unless task.include.nil? || task.include.empty?
527
618
  args.concat(opts)
528
- args.append(File.join(volumes.volume(task.source_id).root, task.source_root), File.join(volumes.volume(task.destination_id).root, task.destination_root))
619
+ case task.crypter_mode
620
+ when nil then args.append(source_path, destination_path)
621
+ when :encrypt then args.append(source_path, ':crypt:')
622
+ when :decrypt then args.append(':crypt:', destination_path)
623
+ end
624
+ $stdout << args.collect(&:escape).join(' ') << "\n" if verbose?
529
625
  case system(*args)
530
- when nil then raise(Session::Error, %(failed to execute "#{args.first}"))
531
- when false then exit($?)
626
+ when nil
627
+ $stderr << %(failed to execute "#{args.first}") << "\n" if verbose?
628
+ failed = true
629
+ when false
630
+ $stderr << %(Rclone exited with status #{$?.to_i}) << "\n" if verbose?
631
+ failed = true
532
632
  end
533
633
  end
534
- end
535
-
536
- # Collect all tasks from all loaded volumes
537
- def tasks
538
- tasks = SessionTaskSet.new(self)
539
- volumes.each { |volume| tasks.merge!(volume.tasks) }
540
- tasks
634
+ raise(Session::Error, "Rclone execution failure(s)") if failed
635
+ self
541
636
  end
542
637
 
543
638
  # Collect all tasks from all loaded volumes which are ready to be executed
544
639
  def intact_tasks
545
- tasks = IntactTaskSet.new(self)
546
- volumes.each { |volume| tasks.merge!(volume.tasks) }
547
- tasks
640
+ IntactTaskSet.new(self).merge!(tasks)
548
641
  end
549
642
 
550
643
  #
@@ -587,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|run)!
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 $mclone_PATH environment variable
701
+ # Look for the $MCLONE_PATH environment variable
604
702
  def self.environment_mounts
605
703
  ENV['MCLONE_PATH'].split(PATH_LIST_SEPARATOR).collect { |path| File.directory?(path) ? path : nil }.compact rescue []
606
704
  end
607
-
608
705
  # Return list of live system-managed mounts (mount points on *NIX and disk drives on Windows) which may contain Mclone volumes
609
- case RbConfig::CONFIG['target_os']
610
- when 'linux'
611
- # Linux OS
706
+ if RUBY_PLATFORM =~ /java/
707
+ require 'java'
612
708
  def self.system_mounts
613
- # Query on /proc for currently mounted file systems
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
- # Generic *NIX-like OS, including Cygwin & MSYS(2)
641
- def self.system_mounts
642
- # Use $(mount) system utility to obtain currently mounted file systems
643
- %x(mount).split("\n").collect do |line|
644
- mount = line.split[2]
645
- UNIX_SYSTEM_MOUNTS.match?(mount) || !File.directory?(mount) ? nil : mount
646
- end.compact
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.1.1
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: 2021-06-24 00:00:00.000000000 Z
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.2.20
61
+ rubygems_version: 3.3.7
61
62
  signing_key:
62
63
  specification_version: 4
63
64
  summary: Rclone frontend for offline synchronization