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.
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