aspera-cli 4.25.6 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +74 -47
- data/CONTRIBUTING.md +1 -1
- data/lib/aspera/api/aoc.rb +118 -78
- data/lib/aspera/api/node.rb +101 -49
- data/lib/aspera/ascp/installation.rb +94 -30
- data/lib/aspera/cli/extended_value.rb +1 -0
- data/lib/aspera/cli/formatter.rb +47 -40
- data/lib/aspera/cli/manager.rb +30 -4
- data/lib/aspera/cli/plugins/aoc.rb +214 -136
- data/lib/aspera/cli/plugins/ats.rb +3 -3
- data/lib/aspera/cli/plugins/base.rb +17 -42
- data/lib/aspera/cli/plugins/config.rb +5 -3
- data/lib/aspera/cli/plugins/console.rb +3 -3
- data/lib/aspera/cli/plugins/faspex.rb +5 -5
- data/lib/aspera/cli/plugins/faspex5.rb +20 -18
- data/lib/aspera/cli/plugins/node.rb +66 -70
- 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/version.rb +1 -1
- data/lib/aspera/dot_container.rb +7 -3
- data/lib/aspera/environment.rb +3 -2
- data/lib/aspera/log.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/rest.rb +1 -0
- data/lib/aspera/rest_list.rb +23 -16
- data/lib/aspera/secret_hider.rb +3 -1
- data/lib/aspera/uri_reader.rb +17 -2
- data.tar.gz.sig +0 -0
- metadata +5 -5
- metadata.gz.sig +0 -0
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)
|
|
@@ -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']
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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,8 @@ 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
|
+
# @param &block called with: file path, data stream, link target if link?
|
|
245
247
|
def extract_archive_files(sdk_archive_path)
|
|
246
248
|
Aspera.assert(block_given?){'missing block'}
|
|
247
249
|
case sdk_archive_path
|
|
@@ -275,32 +277,33 @@ module Aspera
|
|
|
275
277
|
end
|
|
276
278
|
|
|
277
279
|
# Retrieves ascp binary for current system architecture from URL or file
|
|
278
|
-
# @param
|
|
279
|
-
# @param
|
|
280
|
-
# @param
|
|
281
|
-
# @param
|
|
282
|
-
# @param &block
|
|
280
|
+
# @param folder [String] Destination folder path
|
|
281
|
+
# @param url [nil, String] URL to SDK archive, if nil: default url for version
|
|
282
|
+
# @param version [nil, String] Specific version, if nil: latest version
|
|
283
|
+
# @param backup [Boolean] If destination folder exists, then rename
|
|
284
|
+
# @param &block [nil, Proc] A lambda that receives a file path from archive and tells destination sub folder(end with /) or file, or nil to not extract
|
|
283
285
|
# @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)
|
|
286
|
+
def install_sdk(folder:, url: nil, version: nil, backup: true)
|
|
287
|
+
url ||= sdk_url_for_platform(version: version)
|
|
288
|
+
# Rename old install
|
|
289
|
+
if backup && Dir.exist?(folder) && !Dir.empty?(folder)
|
|
297
290
|
Log.log.warn('Previous install exists, renaming folder.')
|
|
298
291
|
File.rename(folder, "#{folder}.#{Time.now.strftime('%Y%m%d%H%M%S')}")
|
|
299
|
-
# TODO:
|
|
292
|
+
# TODO: cleanup old archives ?
|
|
300
293
|
end
|
|
294
|
+
FileUtils.mkdir_p(folder)
|
|
295
|
+
# Security: Track extracted file paths to detect basename collisions
|
|
296
|
+
extracted_files = {}
|
|
297
|
+
# Security: Get canonical path of installation directory for boundary checks
|
|
298
|
+
install_boundary = File.realpath(folder)
|
|
301
299
|
sdk_archive_path = UriReader.read_as_file(url)
|
|
302
300
|
extract_archive_files(sdk_archive_path) do |entry_name, entry_stream, link_target|
|
|
303
|
-
dest_folder =
|
|
301
|
+
dest_folder = if block_given?
|
|
302
|
+
yield(entry_name)
|
|
303
|
+
else
|
|
304
|
+
# default files to extract directly to main folder if in selected source folders
|
|
305
|
+
Products::Transferd::RUNTIME_FOLDERS.any?{ |i| entry_name.match?(%r{^[^/]*/#{i}/})} ? '/' : nil
|
|
306
|
+
end
|
|
304
307
|
next if dest_folder.nil?
|
|
305
308
|
dest_folder = File.join(folder, dest_folder)
|
|
306
309
|
if dest_folder.end_with?('/')
|
|
@@ -309,26 +312,86 @@ module Aspera
|
|
|
309
312
|
dest_file = dest_folder
|
|
310
313
|
dest_folder = File.dirname(dest_file)
|
|
311
314
|
end
|
|
315
|
+
# Security: Detect basename collisions that could overwrite symlinks
|
|
316
|
+
file_basename = File.basename(dest_file)
|
|
317
|
+
if extracted_files.key?(file_basename)
|
|
318
|
+
Log.log.warn{"Rejecting file with duplicate basename: #{entry_name} (basename: #{file_basename}, previous: #{extracted_files[file_basename]})"}
|
|
319
|
+
next
|
|
320
|
+
end
|
|
321
|
+
extracted_files[file_basename] = entry_name
|
|
312
322
|
FileUtils.mkdir_p(dest_folder)
|
|
313
323
|
if link_target.nil?
|
|
324
|
+
# Security: Check if destination already exists
|
|
325
|
+
if File.exist?(dest_file)
|
|
326
|
+
Log.log.warn{"Rejecting write to existing file or link: #{dest_file}"}
|
|
327
|
+
next
|
|
328
|
+
end
|
|
329
|
+
# Security: Verify the resolved path stays within installation boundary
|
|
330
|
+
begin
|
|
331
|
+
# Create parent directory if needed for realpath check
|
|
332
|
+
FileUtils.mkdir_p(File.dirname(dest_file))
|
|
333
|
+
# Check where the file would resolve to (handles existing symlinks in path)
|
|
334
|
+
resolved_dest = File.realpath(File.dirname(dest_file))
|
|
335
|
+
unless resolved_dest.start_with?(install_boundary)
|
|
336
|
+
Log.log.warn{"Rejecting file outside installation directory: #{dest_file} resolves to #{resolved_dest}"}
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
rescue Errno::ENOENT
|
|
340
|
+
# Directory doesn't exist yet, verify the intended path
|
|
341
|
+
unless dest_file.start_with?(folder)
|
|
342
|
+
Log.log.warn{"Rejecting file with path outside installation directory: #{dest_file}"}
|
|
343
|
+
next
|
|
344
|
+
end
|
|
345
|
+
end
|
|
314
346
|
File.open(dest_file, 'wb'){ |output_stream| IO.copy_stream(entry_stream, output_stream)}
|
|
315
347
|
else
|
|
348
|
+
# Security: Validate symlink target stays within installation boundary
|
|
349
|
+
# Resolve the symlink target relative to its location
|
|
350
|
+
link_dir = File.dirname(dest_file)
|
|
351
|
+
resolved_target = if link_target.start_with?('/')
|
|
352
|
+
# Absolute symlink target
|
|
353
|
+
link_target
|
|
354
|
+
else
|
|
355
|
+
# Relative symlink target
|
|
356
|
+
File.expand_path(link_target, link_dir)
|
|
357
|
+
end
|
|
358
|
+
# Check if resolved target would be outside installation directory
|
|
359
|
+
unless resolved_target.start_with?(install_boundary)
|
|
360
|
+
Log.log.warn{"Rejecting symlink pointing outside installation directory: #{entry_name} -> #{link_target} (resolves to #{resolved_target})"}
|
|
361
|
+
next
|
|
362
|
+
end
|
|
316
363
|
File.symlink(link_target, dest_file)
|
|
317
364
|
end
|
|
318
365
|
end
|
|
319
|
-
|
|
320
|
-
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Retrieve SDK either from specified URL, or specified version from standard location, or latest version
|
|
369
|
+
# @param url [nil, String] URL
|
|
370
|
+
# @param version [nil, String] URL
|
|
371
|
+
def retrieve_sdk(url: nil, version: nil)
|
|
372
|
+
folder = Products::Transferd.sdk_directory
|
|
373
|
+
install_sdk(folder: folder, url: url, version: version)
|
|
374
|
+
# Ensure necessary files are there, or generate them, restrict file access on SDK executables
|
|
321
375
|
SDK_FILES.each do |file_id_sym|
|
|
322
376
|
file_path = path(file_id_sym)
|
|
323
377
|
if file_path && EXE_FILES.include?(file_id_sym)
|
|
324
378
|
Environment.restrict_file_access(file_path, mode: 0o755) if File.exist?(file_path)
|
|
325
379
|
end
|
|
326
380
|
end
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
381
|
+
# Generate meta data XML file in SDK if needed, based on actual binaries versions
|
|
382
|
+
meta_data_file = File.join(folder, Products::Other::INFO_META_FILE)
|
|
383
|
+
if File.exist?(meta_data_file)
|
|
384
|
+
# file is there, read values:
|
|
385
|
+
meta_data = File.read(meta_data_file)
|
|
386
|
+
sdk_name = meta_data.scan(%r{<name>(.*?)</name>}).flatten.first || 'unknown'
|
|
387
|
+
sdk_version = meta_data.scan(%r{<version>(.*?)</version>}).flatten.first || 'unknown'
|
|
388
|
+
else
|
|
389
|
+
sdk_ascp_version = get_ascp_version(path(:ascp))
|
|
390
|
+
transferd_version = get_exe_version(path(:transferd), 'version')
|
|
391
|
+
sdk_name = 'IBM Aspera Transfer SDK'
|
|
392
|
+
sdk_version = transferd_version || sdk_ascp_version
|
|
393
|
+
File.write(meta_data_file, "<product><name>#{sdk_name}</name><version>#{sdk_version}</version></product>")
|
|
394
|
+
end
|
|
332
395
|
return sdk_name, sdk_version, folder
|
|
333
396
|
end
|
|
334
397
|
|
|
@@ -349,6 +412,7 @@ module Aspera
|
|
|
349
412
|
END_OF_CONFIG_FILE
|
|
350
413
|
# all executable files from SDK
|
|
351
414
|
EXE_FILES = %i[ascp ascp4 async transferd].freeze
|
|
415
|
+
# IDs of files present in SDK
|
|
352
416
|
SDK_FILES = %i[ssh_private_dsa ssh_private_rsa aspera_license aspera_conf fallback_certificate fallback_private_key].unshift(*EXE_FILES).freeze
|
|
353
417
|
TRANSFERD_ARCHIVE_LOCATION_URL = 'https://ibm.biz/sdk_location'
|
|
354
418
|
# filename for ascp with optional extension (Windows)
|
|
@@ -96,6 +96,7 @@ module Aspera
|
|
|
96
96
|
path: lambda{ |i| File.expand_path(i)},
|
|
97
97
|
re: lambda{ |i| Regexp.new(i, Regexp::MULTILINE)},
|
|
98
98
|
ruby: lambda{ |i| Environment.secure_eval(i, __FILE__, __LINE__)},
|
|
99
|
+
s: lambda{ |i| i.to_s},
|
|
99
100
|
secret: lambda{ |i| prompt = i.empty? ? 'secret' : i; $stdin.getpass("#{prompt}> ")}, # rubocop:disable Style/Semicolon
|
|
100
101
|
stdin: lambda{ |i| ExtendedValue.read_stdin(i)},
|
|
101
102
|
yaml: lambda{ |i| YAML.load(i)},
|