aspera-cli 4.25.6 → 4.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +89 -48
- data/CONTRIBUTING.md +1 -1
- data/lib/aspera/api/aoc.rb +120 -79
- data/lib/aspera/api/node.rb +103 -51
- data/lib/aspera/ascp/installation.rb +99 -32
- data/lib/aspera/assert.rb +17 -13
- data/lib/aspera/cli/extended_value.rb +7 -2
- data/lib/aspera/cli/formatter.rb +107 -95
- data/lib/aspera/cli/main.rb +69 -10
- data/lib/aspera/cli/manager.rb +158 -78
- data/lib/aspera/cli/options.schema.yaml +82 -0
- data/lib/aspera/cli/plugins/aoc.rb +247 -144
- data/lib/aspera/cli/plugins/ats.rb +3 -3
- data/lib/aspera/cli/plugins/base.rb +60 -76
- data/lib/aspera/cli/plugins/config.rb +14 -12
- data/lib/aspera/cli/plugins/console.rb +3 -3
- data/lib/aspera/cli/plugins/faspex.rb +6 -6
- data/lib/aspera/cli/plugins/faspex5.rb +24 -23
- data/lib/aspera/cli/plugins/node.rb +67 -71
- data/lib/aspera/cli/plugins/oauth.rb +5 -12
- data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
- data/lib/aspera/cli/plugins/preview.rb +116 -80
- data/lib/aspera/cli/plugins/server.rb +2 -10
- data/lib/aspera/cli/plugins/shares.rb +7 -7
- data/lib/aspera/cli/sync_actions.rb +1 -1
- data/lib/aspera/cli/transfer_agent.rb +17 -15
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/command_line_builder.rb +22 -18
- data/lib/aspera/dot_container.rb +7 -3
- data/lib/aspera/environment.rb +6 -5
- data/lib/aspera/formatter_interface.rb +14 -0
- data/lib/aspera/hash_ext.rb +6 -0
- data/lib/aspera/log.rb +5 -4
- data/lib/aspera/markdown.rb +4 -1
- data/lib/aspera/oauth/factory.rb +1 -1
- data/lib/aspera/preview/file_types.rb +1 -1
- data/lib/aspera/preview/generator.rb +146 -91
- data/lib/aspera/preview/options.rb +4 -1
- data/lib/aspera/preview/terminal.rb +50 -20
- data/lib/aspera/preview/utils.rb +76 -34
- data/lib/aspera/products/transferd.rb +1 -1
- data/lib/aspera/proxy_auto_config.rb +3 -0
- data/lib/aspera/rest.rb +2 -1
- data/lib/aspera/rest_list.rb +23 -16
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +62801 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +8898 -0
- data/lib/aspera/schema/documentation.rb +107 -0
- data/lib/aspera/schema/reader.rb +75 -0
- data/lib/aspera/schema/registry.rb +63 -0
- data/lib/aspera/secret_hider.rb +3 -1
- data/lib/aspera/sync/conf.schema.yaml +0 -26
- data/lib/aspera/sync/operations.rb +9 -5
- data/lib/aspera/transfer/faux_file.rb +1 -1
- data/lib/aspera/transfer/resumer.rb +1 -1
- data/lib/aspera/transfer/spec.rb +3 -3
- data/lib/aspera/transfer/spec.schema.yaml +1 -1
- data/lib/aspera/uri_reader.rb +17 -2
- data/lib/aspera/yaml.rb +4 -2
- data.tar.gz.sig +0 -0
- metadata +13 -7
- metadata.gz.sig +0 -0
- data/lib/aspera/transfer/spec_doc.rb +0 -76
data/lib/aspera/api/node.rb
CHANGED
|
@@ -14,6 +14,22 @@ require 'net/ssh/buffer'
|
|
|
14
14
|
|
|
15
15
|
module Aspera
|
|
16
16
|
module Api
|
|
17
|
+
# Combination of Node API with access key and file identifier on that node
|
|
18
|
+
class NodeFileId
|
|
19
|
+
# @param api [Aspera::Api::Node] API client
|
|
20
|
+
attr_reader :node_api
|
|
21
|
+
# @param id [String] File identifier
|
|
22
|
+
attr_reader :file_id
|
|
23
|
+
|
|
24
|
+
# Initialize a new instance of NodeFileId
|
|
25
|
+
# @param api [Aspera::Api::Node] API client
|
|
26
|
+
# @param id [String] File identifier in access key
|
|
27
|
+
def initialize(api, id)
|
|
28
|
+
@node_api = api
|
|
29
|
+
@file_id = id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
17
33
|
# Aspera Node API client
|
|
18
34
|
# with gen4 extensions (access keys)
|
|
19
35
|
class Node < Rest
|
|
@@ -32,23 +48,18 @@ module Aspera
|
|
|
32
48
|
SIGNATURE_DELIMITER = '==SIGNATURE=='
|
|
33
49
|
# Default validity when generating a bearer token "manually"
|
|
34
50
|
BEARER_TOKEN_VALIDITY_DEFAULT = 86400
|
|
35
|
-
# Fields in @app_info
|
|
36
|
-
REQUIRED_APP_INFO_FIELDS = %i[api app node_info workspace_id workspace_name].freeze
|
|
37
|
-
# Methods of @app_info[:api]
|
|
38
|
-
REQUIRED_APP_API_METHODS = %i[node_api_from add_ts_tags].freeze
|
|
39
51
|
private_constant :MATCH_TYPES,
|
|
40
|
-
:SIGNATURE_DELIMITER, :BEARER_TOKEN_VALIDITY_DEFAULT
|
|
41
|
-
:REQUIRED_APP_INFO_FIELDS, :REQUIRED_APP_API_METHODS
|
|
52
|
+
:SIGNATURE_DELIMITER, :BEARER_TOKEN_VALIDITY_DEFAULT
|
|
42
53
|
# Node API permissions: delete list mkdir preview read rename write
|
|
43
54
|
ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
|
|
44
55
|
# Special HTTP Headers
|
|
45
56
|
HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
|
|
46
57
|
HEADER_X_CACHE_CONTROL = 'X-Aspera-Cache-Control'
|
|
47
|
-
HEADER_X_TOTAL_COUNT = 'X-Total-Count'
|
|
48
58
|
HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
|
|
49
59
|
HEADER_ACCEPT_VERSION = 'Accept-Version'
|
|
50
60
|
# / in cloud
|
|
51
61
|
PATH_SEPARATOR = '/'
|
|
62
|
+
HEADER_X_TOTAL_COUNT = 'X-Total-Count'
|
|
52
63
|
|
|
53
64
|
# Register node special token decoder
|
|
54
65
|
OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
|
|
@@ -95,6 +106,7 @@ module Aspera
|
|
|
95
106
|
|
|
96
107
|
# Adds fields `public_keys` in provided Hash, if dynamic key is set.
|
|
97
108
|
# @param h [Hash] Hash to add public key to
|
|
109
|
+
# @return [Hash] Hash with public key added
|
|
98
110
|
def add_public_key(h)
|
|
99
111
|
if @dynamic_key
|
|
100
112
|
ssh_key = Net::SSH::Buffer.from(:key, @dynamic_key)
|
|
@@ -153,7 +165,7 @@ module Aspera
|
|
|
153
165
|
items = scope.split(Scope::SEPARATOR, 2)
|
|
154
166
|
Aspera.assert(items.length.eql?(2)){"invalid scope: #{scope}"}
|
|
155
167
|
Aspera.assert(items[0].start_with?(Scope::NODE_PREFIX)){"invalid scope: #{scope}"}
|
|
156
|
-
return {access_key: items[0]
|
|
168
|
+
return {access_key: items[0].delete_prefix(Scope::NODE_PREFIX), scope: items[1]}
|
|
157
169
|
end
|
|
158
170
|
|
|
159
171
|
# Create an Aspera Node bearer token
|
|
@@ -202,13 +214,14 @@ module Aspera
|
|
|
202
214
|
end
|
|
203
215
|
end
|
|
204
216
|
|
|
217
|
+
# @return [Api::AoC::AppInfo,nil] set for AoC
|
|
205
218
|
attr_reader :app_info
|
|
206
219
|
|
|
207
|
-
# @param app_info [
|
|
208
|
-
# @param add_tspec [Hash,
|
|
209
|
-
# @param base_url [String]
|
|
210
|
-
# @param auth [String,
|
|
211
|
-
# @param headers [String,
|
|
220
|
+
# @param app_info [Api::AoC::AppInfo,nil] App information, typically AoC
|
|
221
|
+
# @param add_tspec [Hash,nil] Additional transfer spec
|
|
222
|
+
# @param base_url [String] Rest parameters
|
|
223
|
+
# @param auth [String,nil] Rest parameters
|
|
224
|
+
# @param headers [String,nil] Rest parameters
|
|
212
225
|
def initialize(app_info: nil, add_tspec: nil, **rest_args)
|
|
213
226
|
# Init Rest
|
|
214
227
|
super(**rest_args)
|
|
@@ -217,14 +230,6 @@ module Aspera
|
|
|
217
230
|
# This is added to transfer spec, for instance to add tags (COS)
|
|
218
231
|
@add_tspec = add_tspec
|
|
219
232
|
@std_t_spec_cache = nil
|
|
220
|
-
if !@app_info.nil?
|
|
221
|
-
REQUIRED_APP_INFO_FIELDS.each do |field|
|
|
222
|
-
Aspera.assert(@app_info.key?(field)){"app_info lacks field #{field}"}
|
|
223
|
-
end
|
|
224
|
-
REQUIRED_APP_API_METHODS.each do |method|
|
|
225
|
-
Aspera.assert(@app_info[:api].respond_to?(method)){"#{@app_info[:api].class} lacks method #{method}"}
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
233
|
end
|
|
229
234
|
|
|
230
235
|
# Update transfer spec with special additional tags
|
|
@@ -235,18 +240,21 @@ module Aspera
|
|
|
235
240
|
return tspec
|
|
236
241
|
end
|
|
237
242
|
|
|
238
|
-
# @returns [Node] a Node Api object or nil if no App defined
|
|
243
|
+
# @returns [Node, nil] a Node Api object or nil if no App defined
|
|
239
244
|
def node_id_to_node(node_id)
|
|
240
245
|
if !@app_info.nil?
|
|
241
|
-
return self if node_id.eql?(@app_info
|
|
242
|
-
return @app_info
|
|
246
|
+
return self if node_id.eql?(@app_info.node_info['id'])
|
|
247
|
+
return @app_info.api.node_api_from(
|
|
243
248
|
node_id: node_id,
|
|
244
|
-
workspace_id: @app_info
|
|
245
|
-
workspace_name: @app_info
|
|
249
|
+
workspace_id: @app_info.workspace_id,
|
|
250
|
+
workspace_name: @app_info.workspace_name
|
|
246
251
|
)
|
|
247
252
|
end
|
|
248
253
|
Log.log.warn{"Cannot resolve link with node id #{node_id}, no resolver"}
|
|
249
254
|
return
|
|
255
|
+
rescue RestCallError => e
|
|
256
|
+
Log.log.warn{"Cannot resolve link with node id #{node_id}: #{e.message}"}
|
|
257
|
+
return
|
|
250
258
|
end
|
|
251
259
|
|
|
252
260
|
# Check if a link entry in folder has target information
|
|
@@ -264,14 +272,26 @@ module Aspera
|
|
|
264
272
|
return false
|
|
265
273
|
end
|
|
266
274
|
|
|
267
|
-
# Read folder content
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
#
|
|
275
|
+
# Read folder content with pagination management for gen4 (non-recursive)
|
|
276
|
+
#
|
|
277
|
+
# Behavior WITHOUT `Accept-Version: 4.0`:
|
|
278
|
+
# - Without `page` and `per_page`: all entries are returned
|
|
279
|
+
# - With `page` or `per_page`: both parameters are required, otherwise returns 400 error
|
|
280
|
+
#
|
|
281
|
+
# Behavior WITH `Accept-Version: 4.0`:
|
|
282
|
+
# - Unavailable query parameters (ignored or return 400 error):
|
|
283
|
+
# page, sort, min_size, max_size, min_modified_time, max_modified_time,
|
|
284
|
+
# target_id, target_node_id, files_prefetch_count, name_iglob
|
|
285
|
+
# - Query parameter `include`: accepted but has no effect (access_levels and recursive_counts already included)
|
|
286
|
+
# - Query parameter `iteration_token`: enables pagination
|
|
287
|
+
# * Response header `X-Aspera-Next-Iteration-Token`: token for next page
|
|
288
|
+
# * Response header `X-Aspera-Total-Count`: total count of entries
|
|
289
|
+
#
|
|
290
|
+
# @param file_id [String] The folder file identifier
|
|
291
|
+
# @param query [Hash, nil] Optional query parameters for the API request
|
|
292
|
+
# @param exception [Boolean] If true, raises exceptions on errors; if false, logs warnings
|
|
293
|
+
# @param path [String, nil] Optional path for logging purposes
|
|
294
|
+
# @return [Array<Hash>] List of folder entries (files, folders, links)
|
|
275
295
|
def read_folder_content(file_id, query = nil, exception: true, path: nil)
|
|
276
296
|
folder_items = []
|
|
277
297
|
begin
|
|
@@ -300,7 +320,7 @@ module Aspera
|
|
|
300
320
|
end
|
|
301
321
|
rescue StandardError => e
|
|
302
322
|
raise e if exception
|
|
303
|
-
Log.log.warn{"#{path}: #{e.class} #{e.message}"}
|
|
323
|
+
Log.log.warn{"#{path || file_id}: #{e.class} #{e.message}"}
|
|
304
324
|
Log.log.debug{(['Backtrace:'] + e.backtrace).join("\n")}
|
|
305
325
|
ensure
|
|
306
326
|
RestParameters.instance.spinner_cb.call(folder_items.count, action: :success)
|
|
@@ -318,7 +338,7 @@ module Aspera
|
|
|
318
338
|
# @para query [Hash, nil] optional query for `read`
|
|
319
339
|
def process_folder_tree(method_sym:, state:, top_file_id:, top_file_path: '/', query: nil)
|
|
320
340
|
Aspera.assert(!top_file_path.nil?){'top_file_path not set'}
|
|
321
|
-
Log.log.debug{"process_folder_tree: node=#{@app_info ? @app_info
|
|
341
|
+
Log.log.debug{"process_folder_tree: node=#{@app_info ? @app_info.node_info['id'] : 'nil'}, file id=#{top_file_id}, path=#{top_file_path}"}
|
|
322
342
|
# Start at top folder
|
|
323
343
|
folders_to_explore = [{id: top_file_id, path: top_file_path}]
|
|
324
344
|
Log.dump(:folders_to_explore, folders_to_explore)
|
|
@@ -361,26 +381,28 @@ module Aspera
|
|
|
361
381
|
# @param top_file_id [String] id initial file id
|
|
362
382
|
# @param path [String] file or folder path (end with "/" is like setting process_last_link)
|
|
363
383
|
# @param process_last_link [Boolean] if true, follow the last link
|
|
364
|
-
# @return [
|
|
365
|
-
# @option return [Rest] :api REST client instance
|
|
366
|
-
# @option return [String] :file_id File identifier
|
|
384
|
+
# @return [NodeFileId] Full reference to file on node
|
|
367
385
|
def resolve_api_fid(top_file_id, path, process_last_link = false)
|
|
368
386
|
Aspera.assert_type(top_file_id, String)
|
|
369
387
|
Aspera.assert_type(path, String)
|
|
370
388
|
process_last_link ||= path.end_with?(PATH_SEPARATOR)
|
|
371
389
|
path_elements = path.split(PATH_SEPARATOR).reject(&:empty?)
|
|
372
|
-
return
|
|
390
|
+
return NodeFileId.new(self, top_file_id) if path_elements.empty?
|
|
373
391
|
resolve_state = {path: path_elements, consumed: [], result: nil, process_last_link: process_last_link}
|
|
374
392
|
process_folder_tree(method_sym: :process_api_fid, state: resolve_state, top_file_id: top_file_id)
|
|
375
393
|
raise ParameterError, "Entry not found: #{resolve_state[:path].first} in /#{resolve_state[:consumed].join(PATH_SEPARATOR)}" if resolve_state[:result].nil?
|
|
376
|
-
Log.log.debug{"resolve_api_fid: #{path} -> #{resolve_state[:result]
|
|
394
|
+
Log.log.debug{"resolve_api_fid: #{path} -> #{resolve_state[:result].node_api.base_url} #{resolve_state[:result].file_id}"}
|
|
377
395
|
return resolve_state[:result]
|
|
378
396
|
end
|
|
379
397
|
|
|
380
398
|
# Given a list of paths, finds a common root and list of sub-paths
|
|
381
399
|
# @param top_file_id [String] Root file id
|
|
382
400
|
# @param paths [Array(Hash)] List of paths
|
|
383
|
-
# @return [Array]
|
|
401
|
+
# @return [Array<(NodeFileId, Array<Hash>)>] Tuple containing the file identifier and paths
|
|
402
|
+
# - [0] NodeFileId: Reference to the file on the node
|
|
403
|
+
# - [1] Array<Hash>: Transfer paths, each Hash having:
|
|
404
|
+
# - 'source' [String]: Source path
|
|
405
|
+
# - 'destination' [String]: Destination path (optional)
|
|
384
406
|
def resolve_api_fid_paths(top_file_id, paths)
|
|
385
407
|
Aspera.assert_type(paths, Array)
|
|
386
408
|
Aspera.assert(paths.size.positive?)
|
|
@@ -401,7 +423,7 @@ module Aspera
|
|
|
401
423
|
# If a single item
|
|
402
424
|
if source_paths.size.eql?(1)
|
|
403
425
|
# Get precise info in this element
|
|
404
|
-
file_info = apifid
|
|
426
|
+
file_info = apifid.node_api.read("files/#{apifid.file_id}")
|
|
405
427
|
source_paths =
|
|
406
428
|
case file_info['type']
|
|
407
429
|
when 'file'
|
|
@@ -496,15 +518,16 @@ module Aspera
|
|
|
496
518
|
add_tspec_info(transfer_spec)
|
|
497
519
|
transfer_spec.deep_merge!(ts_merge) unless ts_merge.nil?
|
|
498
520
|
# Add application specific tags (AoC)
|
|
499
|
-
@app_info
|
|
521
|
+
@app_info&.api&.add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info)
|
|
500
522
|
# Add remote host info
|
|
501
523
|
if self.class.api_options[:standard_ports]
|
|
502
524
|
# Get default TCP/UDP ports and transfer user
|
|
503
525
|
transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
|
|
504
526
|
# By default: same address as node API
|
|
505
527
|
transfer_spec['remote_host'] = URI.parse(base_url).host
|
|
506
|
-
# AoC allows specification of other url
|
|
507
|
-
|
|
528
|
+
# AoC allows specification of other url (in UI: `Transfer endpoint (optional)`)
|
|
529
|
+
transfer_url = @app_info&.node_info&.[]('transfer_url').to_s
|
|
530
|
+
transfer_spec['remote_host'] = transfer_url unless transfer_url.empty?
|
|
508
531
|
info = read('info')
|
|
509
532
|
# Get the transfer user from info on access key
|
|
510
533
|
transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
|
|
@@ -517,7 +540,7 @@ module Aspera
|
|
|
517
540
|
else
|
|
518
541
|
transfer_spec.merge!(transport_params)
|
|
519
542
|
end
|
|
520
|
-
Aspera.assert_values(transfer_spec['remote_user'], Transfer::Spec::ACCESS_KEY_TRANSFER_USER, type: :warn){'transfer user'}
|
|
543
|
+
Aspera.assert_values(transfer_spec['remote_user'], [Transfer::Spec::ACCESS_KEY_TRANSFER_USER], type: :warn){'transfer user'}
|
|
521
544
|
return transfer_spec
|
|
522
545
|
end
|
|
523
546
|
|
|
@@ -574,6 +597,35 @@ module Aspera
|
|
|
574
597
|
item_list
|
|
575
598
|
end
|
|
576
599
|
|
|
600
|
+
# Read resource content with pagination (page, per_page)
|
|
601
|
+
#
|
|
602
|
+
# @param subpath [String] API Path
|
|
603
|
+
# @param query [Hash, nil] Optional query parameters for the API request
|
|
604
|
+
# @param kwargs [Hash] Other parameters for Rest.read
|
|
605
|
+
#
|
|
606
|
+
# @return [Array<Hash>] List of folder entries (files, folders, links)
|
|
607
|
+
def read_with_pages(subpath, query = nil, **kwargs)
|
|
608
|
+
items = []
|
|
609
|
+
query ||= {}
|
|
610
|
+
query['per_page'] ||= 500
|
|
611
|
+
query['page'] ||= 1
|
|
612
|
+
suffix = nil
|
|
613
|
+
loop do
|
|
614
|
+
RestParameters.instance.spinner_cb.call("#{items.count}#{suffix}")
|
|
615
|
+
data, http = read(subpath, query, **kwargs, ret: :both)
|
|
616
|
+
items.concat(data)
|
|
617
|
+
break if data.length < query['per_page']
|
|
618
|
+
suffix ||= "/#{http[HEADER_X_TOTAL_COUNT]}" if http[HEADER_X_TOTAL_COUNT]
|
|
619
|
+
query['page'] += 1
|
|
620
|
+
end
|
|
621
|
+
rescue StandardError => e
|
|
622
|
+
Log.log.warn{"#{e.class} #{e.message}"}
|
|
623
|
+
Log.log.debug{(['Backtrace:'] + e.backtrace).join("\n")}
|
|
624
|
+
ensure
|
|
625
|
+
RestParameters.instance.spinner_cb.call(items.count, action: :success)
|
|
626
|
+
return items # rubocop:disable Lint/EnsureReturn
|
|
627
|
+
end
|
|
628
|
+
|
|
577
629
|
private
|
|
578
630
|
|
|
579
631
|
# Method called in loop for each entry for `resolve_api_fid`
|
|
@@ -590,12 +642,12 @@ module Aspera
|
|
|
590
642
|
raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless path_fully_consumed
|
|
591
643
|
# It's terminal, we found it
|
|
592
644
|
Log.log.debug{"process_api_fid: found #{path} -> #{entry['id']}"}
|
|
593
|
-
state[:result] =
|
|
645
|
+
state[:result] = NodeFileId.new(self, entry['id'])
|
|
594
646
|
return false
|
|
595
647
|
when 'folder'
|
|
596
648
|
if path_fully_consumed
|
|
597
649
|
# We found it
|
|
598
|
-
state[:result] =
|
|
650
|
+
state[:result] = NodeFileId.new(self, entry['id'])
|
|
599
651
|
return false
|
|
600
652
|
end
|
|
601
653
|
when 'link'
|
|
@@ -605,10 +657,10 @@ module Aspera
|
|
|
605
657
|
other_node = nil
|
|
606
658
|
other_node = node_id_to_node(entry['target_node_id']) if entry_has_link_information(entry)
|
|
607
659
|
raise Error, 'Cannot resolve link' if other_node.nil?
|
|
608
|
-
state[:result] =
|
|
660
|
+
state[:result] = NodeFileId.new(other_node, entry['target_id'])
|
|
609
661
|
else
|
|
610
662
|
# We found it but we do not process the link
|
|
611
|
-
state[:result] =
|
|
663
|
+
state[:result] = NodeFileId.new(self, entry['id'])
|
|
612
664
|
end
|
|
613
665
|
return false
|
|
614
666
|
end
|
|
@@ -38,7 +38,7 @@ module Aspera
|
|
|
38
38
|
FIRST_FOUND = 'FIRST'
|
|
39
39
|
|
|
40
40
|
# Loads YAML from cloud with locations of SDK archives for all platforms
|
|
41
|
-
# @return location structure
|
|
41
|
+
# @return [Hash] location structure
|
|
42
42
|
def sdk_locations
|
|
43
43
|
location_url = @transferd_urls
|
|
44
44
|
transferd_locations = UriReader.read(location_url)
|
|
@@ -60,7 +60,7 @@ module Aspera
|
|
|
60
60
|
Aspera.assert(!ascp_location.empty?){'ascp location cannot be empty: check your config file'}
|
|
61
61
|
folder =
|
|
62
62
|
if ascp_location.start_with?(USE_PRODUCT_PREFIX)
|
|
63
|
-
product_name = ascp_location
|
|
63
|
+
product_name = ascp_location.delete_prefix(USE_PRODUCT_PREFIX)
|
|
64
64
|
if product_name.eql?(FIRST_FOUND)
|
|
65
65
|
pl = installed_products.first
|
|
66
66
|
raise "No Aspera transfer module or SDK found.\nRefer to the manual or install SDK with command:\nascli conf transferd install" if pl.nil?
|
|
@@ -74,7 +74,7 @@ module Aspera
|
|
|
74
74
|
end
|
|
75
75
|
Log.log.debug{"ascp_folder=#{folder}"}
|
|
76
76
|
Products::Transferd.sdk_directory = folder
|
|
77
|
-
|
|
77
|
+
nil
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def sdk_folder
|
|
@@ -113,7 +113,7 @@ module Aspera
|
|
|
113
113
|
file = File.join(File.dirname(file), Environment.instance.exe_file(k.to_s)) unless k.eql?(:transferd)
|
|
114
114
|
when :ssh_private_dsa, :ssh_private_rsa
|
|
115
115
|
# assume last 3 letters are type
|
|
116
|
-
type = k.to_s[-3
|
|
116
|
+
type = k.to_s[-3..].to_sym
|
|
117
117
|
file = check_or_create_sdk_file("aspera_bypass_#{type}.pem"){DataRepository.instance.item(type)}
|
|
118
118
|
when :aspera_license
|
|
119
119
|
file = check_or_create_sdk_file('aspera-license'){DataRepository.instance.item(:license)}
|
|
@@ -139,6 +139,7 @@ module Aspera
|
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
# default bypass key phrase
|
|
142
|
+
# @return [String]
|
|
142
143
|
def ssh_cert_uuid
|
|
143
144
|
return DataRepository.instance.item(:uuid)
|
|
144
145
|
end
|
|
@@ -241,7 +242,10 @@ module Aspera
|
|
|
241
242
|
return info.first['url']
|
|
242
243
|
end
|
|
243
244
|
|
|
244
|
-
# @param
|
|
245
|
+
# @param sdk_archive_path [String] path to SDK archive
|
|
246
|
+
# @yieldparam entry_name [String] File path in archive
|
|
247
|
+
# @yieldparam entry_stream [IO, Gem::Package::TarReader::Entry] Data stream
|
|
248
|
+
# @yieldparam link_target [String, nil] Link target if symlink, nil otherwise
|
|
245
249
|
def extract_archive_files(sdk_archive_path)
|
|
246
250
|
Aspera.assert(block_given?){'missing block'}
|
|
247
251
|
case sdk_archive_path
|
|
@@ -275,32 +279,34 @@ module Aspera
|
|
|
275
279
|
end
|
|
276
280
|
|
|
277
281
|
# Retrieves ascp binary for current system architecture from URL or file
|
|
278
|
-
# @param
|
|
279
|
-
# @param
|
|
280
|
-
# @param
|
|
281
|
-
# @param
|
|
282
|
-
# @
|
|
282
|
+
# @param folder [String] Destination folder path
|
|
283
|
+
# @param url [nil, String] URL to SDK archive, if nil: default url for version
|
|
284
|
+
# @param version [nil, String] Specific version, if nil: latest version
|
|
285
|
+
# @param backup [Boolean] If destination folder exists, then rename
|
|
286
|
+
# @yieldparam entry_name [String] File path from archive
|
|
287
|
+
# @yieldreturn [String, nil] Destination sub folder (end with /) or file, or nil to not extract
|
|
283
288
|
# @return [Array] name, ascp version (from execution), folder
|
|
284
|
-
def install_sdk(url: nil, version: nil,
|
|
285
|
-
url
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if subfolder_lambda.nil?
|
|
289
|
-
# default files to extract directly to main folder if in selected source folders
|
|
290
|
-
subfolder_lambda = ->(name) do
|
|
291
|
-
Products::Transferd::RUNTIME_FOLDERS.any?{ |i| name.match?(%r{^[^/]*/#{i}/})} ? '/' : nil
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
FileUtils.mkdir_p(folder)
|
|
295
|
-
# rename old install
|
|
296
|
-
if backup && !Dir.empty?(folder)
|
|
289
|
+
def install_sdk(folder:, url: nil, version: nil, backup: true)
|
|
290
|
+
url ||= sdk_url_for_platform(version: version)
|
|
291
|
+
# Rename old install
|
|
292
|
+
if backup && Dir.exist?(folder) && !Dir.empty?(folder)
|
|
297
293
|
Log.log.warn('Previous install exists, renaming folder.')
|
|
298
294
|
File.rename(folder, "#{folder}.#{Time.now.strftime('%Y%m%d%H%M%S')}")
|
|
299
|
-
# TODO:
|
|
295
|
+
# TODO: cleanup old archives ?
|
|
300
296
|
end
|
|
297
|
+
FileUtils.mkdir_p(folder)
|
|
298
|
+
# Security: Track extracted file paths to detect basename collisions
|
|
299
|
+
extracted_files = {}
|
|
300
|
+
# Security: Get canonical path of installation directory for boundary checks
|
|
301
|
+
install_boundary = File.realpath(folder)
|
|
301
302
|
sdk_archive_path = UriReader.read_as_file(url)
|
|
302
303
|
extract_archive_files(sdk_archive_path) do |entry_name, entry_stream, link_target|
|
|
303
|
-
dest_folder =
|
|
304
|
+
dest_folder = if block_given?
|
|
305
|
+
yield(entry_name)
|
|
306
|
+
else
|
|
307
|
+
# default files to extract directly to main folder if in selected source folders
|
|
308
|
+
Products::Transferd::RUNTIME_FOLDERS.any?{ |i| entry_name.match?(%r{^[^/]*/#{i}/})} ? '/' : nil
|
|
309
|
+
end
|
|
304
310
|
next if dest_folder.nil?
|
|
305
311
|
dest_folder = File.join(folder, dest_folder)
|
|
306
312
|
if dest_folder.end_with?('/')
|
|
@@ -309,26 +315,86 @@ module Aspera
|
|
|
309
315
|
dest_file = dest_folder
|
|
310
316
|
dest_folder = File.dirname(dest_file)
|
|
311
317
|
end
|
|
318
|
+
# Security: Detect basename collisions that could overwrite symlinks
|
|
319
|
+
file_basename = File.basename(dest_file)
|
|
320
|
+
if extracted_files.key?(file_basename)
|
|
321
|
+
Log.log.warn{"Rejecting file with duplicate basename: #{entry_name} (basename: #{file_basename}, previous: #{extracted_files[file_basename]})"}
|
|
322
|
+
next
|
|
323
|
+
end
|
|
324
|
+
extracted_files[file_basename] = entry_name
|
|
312
325
|
FileUtils.mkdir_p(dest_folder)
|
|
313
326
|
if link_target.nil?
|
|
327
|
+
# Security: Check if destination already exists
|
|
328
|
+
if File.exist?(dest_file)
|
|
329
|
+
Log.log.warn{"Rejecting write to existing file or link: #{dest_file}"}
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
# Security: Verify the resolved path stays within installation boundary
|
|
333
|
+
begin
|
|
334
|
+
# Create parent directory if needed for realpath check
|
|
335
|
+
FileUtils.mkdir_p(File.dirname(dest_file))
|
|
336
|
+
# Check where the file would resolve to (handles existing symlinks in path)
|
|
337
|
+
resolved_dest = File.realpath(File.dirname(dest_file))
|
|
338
|
+
unless resolved_dest.start_with?(install_boundary)
|
|
339
|
+
Log.log.warn{"Rejecting file outside installation directory: #{dest_file} resolves to #{resolved_dest}"}
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
rescue Errno::ENOENT
|
|
343
|
+
# Directory doesn't exist yet, verify the intended path
|
|
344
|
+
unless dest_file.start_with?(folder)
|
|
345
|
+
Log.log.warn{"Rejecting file with path outside installation directory: #{dest_file}"}
|
|
346
|
+
next
|
|
347
|
+
end
|
|
348
|
+
end
|
|
314
349
|
File.open(dest_file, 'wb'){ |output_stream| IO.copy_stream(entry_stream, output_stream)}
|
|
315
350
|
else
|
|
351
|
+
# Security: Validate symlink target stays within installation boundary
|
|
352
|
+
# Resolve the symlink target relative to its location
|
|
353
|
+
link_dir = File.dirname(dest_file)
|
|
354
|
+
resolved_target = if link_target.start_with?('/')
|
|
355
|
+
# Absolute symlink target
|
|
356
|
+
link_target
|
|
357
|
+
else
|
|
358
|
+
# Relative symlink target
|
|
359
|
+
File.expand_path(link_target, link_dir)
|
|
360
|
+
end
|
|
361
|
+
# Check if resolved target would be outside installation directory
|
|
362
|
+
unless resolved_target.start_with?(install_boundary)
|
|
363
|
+
Log.log.warn{"Rejecting symlink pointing outside installation directory: #{entry_name} -> #{link_target} (resolves to #{resolved_target})"}
|
|
364
|
+
next
|
|
365
|
+
end
|
|
316
366
|
File.symlink(link_target, dest_file)
|
|
317
367
|
end
|
|
318
368
|
end
|
|
319
|
-
|
|
320
|
-
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Retrieve SDK either from specified URL, or specified version from standard location, or latest version
|
|
372
|
+
# @param url [nil, String] URL
|
|
373
|
+
# @param version [nil, String] URL
|
|
374
|
+
def retrieve_sdk(url: nil, version: nil)
|
|
375
|
+
folder = Products::Transferd.sdk_directory
|
|
376
|
+
install_sdk(folder: folder, url: url, version: version)
|
|
377
|
+
# Ensure necessary files are there, or generate them, restrict file access on SDK executables
|
|
321
378
|
SDK_FILES.each do |file_id_sym|
|
|
322
379
|
file_path = path(file_id_sym)
|
|
323
380
|
if file_path && EXE_FILES.include?(file_id_sym)
|
|
324
381
|
Environment.restrict_file_access(file_path, mode: 0o755) if File.exist?(file_path)
|
|
325
382
|
end
|
|
326
383
|
end
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
384
|
+
# Generate meta data XML file in SDK if needed, based on actual binaries versions
|
|
385
|
+
meta_data_file = File.join(folder, Products::Other::INFO_META_FILE)
|
|
386
|
+
if File.exist?(meta_data_file)
|
|
387
|
+
# file is there, read values:
|
|
388
|
+
meta_data = File.read(meta_data_file)
|
|
389
|
+
sdk_name = meta_data.scan(%r{<name>(.*?)</name>}).flatten.first || 'unknown'
|
|
390
|
+
sdk_version = meta_data.scan(%r{<version>(.*?)</version>}).flatten.first || 'unknown'
|
|
391
|
+
else
|
|
392
|
+
sdk_ascp_version = get_ascp_version(path(:ascp))
|
|
393
|
+
transferd_version = get_exe_version(path(:transferd), 'version')
|
|
394
|
+
sdk_name = 'IBM Aspera Transfer SDK'
|
|
395
|
+
sdk_version = transferd_version || sdk_ascp_version
|
|
396
|
+
File.write(meta_data_file, "<product><name>#{sdk_name}</name><version>#{sdk_version}</version></product>")
|
|
397
|
+
end
|
|
332
398
|
return sdk_name, sdk_version, folder
|
|
333
399
|
end
|
|
334
400
|
|
|
@@ -349,6 +415,7 @@ module Aspera
|
|
|
349
415
|
END_OF_CONFIG_FILE
|
|
350
416
|
# all executable files from SDK
|
|
351
417
|
EXE_FILES = %i[ascp ascp4 async transferd].freeze
|
|
418
|
+
# IDs of files present in SDK
|
|
352
419
|
SDK_FILES = %i[ssh_private_dsa ssh_private_rsa aspera_license aspera_conf fallback_certificate fallback_private_key].unshift(*EXE_FILES).freeze
|
|
353
420
|
TRANSFERD_ARCHIVE_LOCATION_URL = 'https://ibm.biz/sdk_location'
|
|
354
421
|
# filename for ascp with optional extension (Windows)
|
data/lib/aspera/assert.rb
CHANGED
|
@@ -20,21 +20,24 @@ module Aspera
|
|
|
20
20
|
class << self
|
|
21
21
|
# Replaces `raise` in assertion
|
|
22
22
|
# Allows sending exception, or just error log, when type is `:error`
|
|
23
|
-
# @param type [Exception,Symbol] Send to log if symbol, else raise exception
|
|
23
|
+
# @param type [Exception, Symbol] Send to log if symbol, else raise exception
|
|
24
24
|
# @param message [String] Message for error.
|
|
25
|
+
# @raise [Exception]
|
|
26
|
+
# @return [nil]
|
|
25
27
|
def report_error(type, message)
|
|
26
28
|
if type.is_a?(Symbol)
|
|
27
29
|
Log.log.send(type, message)
|
|
28
30
|
else
|
|
29
31
|
raise type, message
|
|
30
32
|
end
|
|
33
|
+
nil
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# Assert that a condition is true, else raise exception
|
|
34
37
|
# @param assertion [TrueClass, FalseClass] Must be true
|
|
35
|
-
# @param info [String,nil] Fixed message in case assert fails, else use
|
|
38
|
+
# @param info [String,nil] Fixed message in case assert fails, else use block
|
|
36
39
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
37
|
-
# @
|
|
40
|
+
# @yieldreturn [String] A string that describes the problem for complex messages
|
|
38
41
|
# The block is executed in the context of the Aspera module
|
|
39
42
|
def assert(assertion, info = nil, type: AssertError)
|
|
40
43
|
raise InternalError, 'bad assert: both info and block given' unless info.nil? || !block_given?
|
|
@@ -50,18 +53,18 @@ module Aspera
|
|
|
50
53
|
# @param value [Object] The value to check
|
|
51
54
|
# @param classes [Class, Array] The expected type(s)
|
|
52
55
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
53
|
-
# @
|
|
56
|
+
# @yieldreturn [String] Additional description to prepend to the error message
|
|
54
57
|
def assert_type(value, *classes, type: AssertError)
|
|
55
|
-
assert(classes.any?{ |k| value.is_a?(k)}, type: type){"#{"#{yield}: " if block_given?}expecting #{classes.join(', ')}, but have (#{value.class})#{value.inspect}"}
|
|
58
|
+
assert(classes.any?{ |k| value.is_a?(k)}, type: type){"#{"#{yield}: " if block_given?}expecting type #{classes.join(', ')}, but have (#{value.class})#{value.inspect}"}
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
# Assert that all value of array are of the same specified type.
|
|
59
62
|
# @param array [Array] The array to check
|
|
60
63
|
# @param klass [Class] The expected type of elements
|
|
61
64
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
62
|
-
# @
|
|
65
|
+
# @yieldreturn [String] Additional description to prepend to the error message
|
|
63
66
|
def assert_array_all(array, klass, type: AssertError)
|
|
64
|
-
assert_type(array, Array, type:
|
|
67
|
+
assert_type(array, Array, type: AssertError){'array'}
|
|
65
68
|
assert(array.all?(klass), type: type){"#{"#{yield}: " if block_given?}expecting all as #{klass}, but have #{array.map(&:class).uniq}"}
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -70,19 +73,20 @@ module Aspera
|
|
|
70
73
|
# @param key_class [Class] The expected type of keys (or nil)
|
|
71
74
|
# @param value_class [Class] The expected type of values (or nil)
|
|
72
75
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
73
|
-
# @
|
|
76
|
+
# @yieldreturn [String] Additional description to prepend to the error message
|
|
74
77
|
def assert_hash_all(hash, key_class, value_class, type: AssertError)
|
|
75
|
-
assert_type(hash, Hash, type:
|
|
76
|
-
assert_array_all(hash.keys, key_class, type:
|
|
77
|
-
assert_array_all(hash.values, value_class, type:
|
|
78
|
+
assert_type(hash, Hash, type: AssertError){'hash'}
|
|
79
|
+
assert_array_all(hash.keys, key_class, type: type){"#{"#{yield}: " if block_given?}keys"} unless key_class.nil?
|
|
80
|
+
assert_array_all(hash.values, value_class, type: type){"#{"#{yield}: " if block_given?}values"} unless value_class.nil?
|
|
78
81
|
end
|
|
79
82
|
|
|
80
83
|
# Assert that value is one of the given values
|
|
81
84
|
# @param value [Object] Value to check
|
|
82
85
|
# @param values [Array] Accepted values
|
|
83
86
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
84
|
-
# @
|
|
87
|
+
# @yieldreturn [String] Additional description to prepend to the error message
|
|
85
88
|
def assert_values(value, values, type: AssertError)
|
|
89
|
+
assert_type(values, Array, type: AssertError){'values'}
|
|
86
90
|
assert(values.include?(value), type: type) do
|
|
87
91
|
val_list = values.inspect
|
|
88
92
|
val_list = "one of #{val_list}" if values.is_a?(Array)
|
|
@@ -93,7 +97,7 @@ module Aspera
|
|
|
93
97
|
# The value is not one of the expected values
|
|
94
98
|
# @param value [Object] The wrong value
|
|
95
99
|
# @param type [Exception,Symbol] Exception to raise, or Symbol for Log.log
|
|
96
|
-
# @
|
|
100
|
+
# @yieldreturn [String] Additional description to prepend to the error message
|
|
97
101
|
def error_unexpected_value(value, type: InternalError)
|
|
98
102
|
report_error(type, "#{"#{yield}: " if block_given?}unexpected value: #{value.inspect}")
|
|
99
103
|
end
|
|
@@ -15,6 +15,10 @@ require 'singleton'
|
|
|
15
15
|
module Aspera
|
|
16
16
|
module Cli
|
|
17
17
|
# Command line extended values
|
|
18
|
+
#
|
|
19
|
+
# @!method self.instance
|
|
20
|
+
# Returns the singleton instance of ExtendedValue
|
|
21
|
+
# @return [ExtendedValue] the singleton instance
|
|
18
22
|
class ExtendedValue
|
|
19
23
|
include Singleton
|
|
20
24
|
|
|
@@ -91,11 +95,12 @@ module Aspera
|
|
|
91
95
|
uri: lambda{ |i| UriReader.read(i)},
|
|
92
96
|
json: lambda{ |i| ExtendedValue.JSON_parse(i)},
|
|
93
97
|
lines: lambda{ |i| i.split("\n")},
|
|
94
|
-
list: lambda{ |i| i[1
|
|
98
|
+
list: lambda{ |i| i[1..].split(i[0])},
|
|
95
99
|
none: lambda{ |i| ExtendedValue.assert_no_value(i, :none); nil}, # rubocop:disable Style/Semicolon
|
|
96
100
|
path: lambda{ |i| File.expand_path(i)},
|
|
97
101
|
re: lambda{ |i| Regexp.new(i, Regexp::MULTILINE)},
|
|
98
102
|
ruby: lambda{ |i| Environment.secure_eval(i, __FILE__, __LINE__)},
|
|
103
|
+
s: lambda{ |i| i.to_s},
|
|
99
104
|
secret: lambda{ |i| prompt = i.empty? ? 'secret' : i; $stdin.getpass("#{prompt}> ")}, # rubocop:disable Style/Semicolon
|
|
100
105
|
stdin: lambda{ |i| ExtendedValue.read_stdin(i)},
|
|
101
106
|
yaml: lambda{ |i| YAML.load(i)},
|
|
@@ -144,7 +149,7 @@ module Aspera
|
|
|
144
149
|
# @param value [String] the value to parse
|
|
145
150
|
# @param context [String] Context in which evaluation is done
|
|
146
151
|
# @param allowed [Array<Class>,NilClass] Expected types
|
|
147
|
-
# @return [
|
|
152
|
+
# @return [String, Integer, Array, Hash, Boolean] Evaluated value
|
|
148
153
|
def evaluate(value, context:, allowed: nil)
|
|
149
154
|
return value unless value.is_a?(String)
|
|
150
155
|
Aspera.assert_array_all(allowed, Class) unless allowed.nil?
|