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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +89 -48
  4. data/CONTRIBUTING.md +1 -1
  5. data/lib/aspera/api/aoc.rb +120 -79
  6. data/lib/aspera/api/node.rb +103 -51
  7. data/lib/aspera/ascp/installation.rb +99 -32
  8. data/lib/aspera/assert.rb +17 -13
  9. data/lib/aspera/cli/extended_value.rb +7 -2
  10. data/lib/aspera/cli/formatter.rb +107 -95
  11. data/lib/aspera/cli/main.rb +69 -10
  12. data/lib/aspera/cli/manager.rb +158 -78
  13. data/lib/aspera/cli/options.schema.yaml +82 -0
  14. data/lib/aspera/cli/plugins/aoc.rb +247 -144
  15. data/lib/aspera/cli/plugins/ats.rb +3 -3
  16. data/lib/aspera/cli/plugins/base.rb +60 -76
  17. data/lib/aspera/cli/plugins/config.rb +14 -12
  18. data/lib/aspera/cli/plugins/console.rb +3 -3
  19. data/lib/aspera/cli/plugins/faspex.rb +6 -6
  20. data/lib/aspera/cli/plugins/faspex5.rb +24 -23
  21. data/lib/aspera/cli/plugins/node.rb +67 -71
  22. data/lib/aspera/cli/plugins/oauth.rb +5 -12
  23. data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
  24. data/lib/aspera/cli/plugins/preview.rb +116 -80
  25. data/lib/aspera/cli/plugins/server.rb +2 -10
  26. data/lib/aspera/cli/plugins/shares.rb +7 -7
  27. data/lib/aspera/cli/sync_actions.rb +1 -1
  28. data/lib/aspera/cli/transfer_agent.rb +17 -15
  29. data/lib/aspera/cli/version.rb +1 -1
  30. data/lib/aspera/command_line_builder.rb +22 -18
  31. data/lib/aspera/dot_container.rb +7 -3
  32. data/lib/aspera/environment.rb +6 -5
  33. data/lib/aspera/formatter_interface.rb +14 -0
  34. data/lib/aspera/hash_ext.rb +6 -0
  35. data/lib/aspera/log.rb +5 -4
  36. data/lib/aspera/markdown.rb +4 -1
  37. data/lib/aspera/oauth/factory.rb +1 -1
  38. data/lib/aspera/preview/file_types.rb +1 -1
  39. data/lib/aspera/preview/generator.rb +146 -91
  40. data/lib/aspera/preview/options.rb +4 -1
  41. data/lib/aspera/preview/terminal.rb +50 -20
  42. data/lib/aspera/preview/utils.rb +76 -34
  43. data/lib/aspera/products/transferd.rb +1 -1
  44. data/lib/aspera/proxy_auto_config.rb +3 -0
  45. data/lib/aspera/rest.rb +2 -1
  46. data/lib/aspera/rest_list.rb +23 -16
  47. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +62801 -0
  48. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +8898 -0
  49. data/lib/aspera/schema/documentation.rb +107 -0
  50. data/lib/aspera/schema/reader.rb +75 -0
  51. data/lib/aspera/schema/registry.rb +63 -0
  52. data/lib/aspera/secret_hider.rb +3 -1
  53. data/lib/aspera/sync/conf.schema.yaml +0 -26
  54. data/lib/aspera/sync/operations.rb +9 -5
  55. data/lib/aspera/transfer/faux_file.rb +1 -1
  56. data/lib/aspera/transfer/resumer.rb +1 -1
  57. data/lib/aspera/transfer/spec.rb +3 -3
  58. data/lib/aspera/transfer/spec.schema.yaml +1 -1
  59. data/lib/aspera/uri_reader.rb +17 -2
  60. data/lib/aspera/yaml.rb +4 -2
  61. data.tar.gz.sig +0 -0
  62. metadata +13 -7
  63. metadata.gz.sig +0 -0
  64. data/lib/aspera/transfer/spec_doc.rb +0 -76
@@ -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][Scope::NODE_PREFIX.length..-1], scope: items[1]}
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 [Hash,NilClass] App information, typically AoC
208
- # @param add_tspec [Hash,NilClass] Additional transfer spec
209
- # @param base_url [String] Rest parameters
210
- # @param auth [String,NilClass] Rest parameters
211
- # @param headers [String,NilClass] Rest parameters
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[:node_info]['id'])
242
- return @app_info[:api].node_api_from(
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[:workspace_id],
245
- workspace_name: @app_info[:workspace_name]
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, with pagination management for gen4, not recursive
268
- # if `Accept-Version: 4.0` is not specified:
269
- # if `page` and `per_page` are not specified, then all entries are returned.
270
- # if either `page` or `per_page` is specified, then both are required, else 400
271
- # if `Accept-Version: 4.0` is specified:
272
- # those queries are not available: page (not mentioned), sort, min_size, max_size, min_modified_time, max_modified_time, target_id, target_node_id, files_prefetch_count, page, name_iglob : either ignored or result in API error 400.
273
- # query include is accepted, but seems to do nothing as access_levels and recursive_counts are already included in results.
274
- # query `iteration_token` is accepted and allows to get paginated results, with `X-Aspera-Next-Iteration-Token` header in response to get next page token. `X-Aspera-Total-Count` header gives total count of entries.
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[:node_info]['id'] : 'nil'}, file id=#{top_file_id}, path=#{top_file_path}"}
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 [Hash] Result data
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 {api: self, file_id: top_file_id} if path_elements.empty?
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][:api].base_url} #{resolve_state[:result][:file_id]}"}
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] size=2: apfid, paths (Array(Hash))
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[:api].read("files/#{apifid[:file_id]}")
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[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info) unless @app_info.nil?
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
- transfer_spec['remote_host'] = @app_info[:node_info]['transfer_url'] if !@app_info.nil? && !@app_info[:node_info]['transfer_url'].nil? && !@app_info[:node_info]['transfer_url'].empty?
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] = {api: self, file_id: entry['id']}
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] = {api: self, file_id: entry['id']}
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] = {api: other_node, file_id: entry['target_id']}
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] = {api: self, file_id: entry['id']}
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[USE_PRODUCT_PREFIX.length..-1]
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
- return
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..-1].to_sym
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 &block called with entry information
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 url [String] URL to SDK archive, or SpecialValues::DEF
279
- # @param folder [String] Destination folder path
280
- # @param backup [Boolean] If destination folder exists, then rename
281
- # @param with_exe [Boolean] If false, only retrieves files, but do not generate or restrict access
282
- # @param &block [Proc] A lambda that receives a file path from archive and tells destination sub folder(end with /) or file, or nil to not extract
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, folder: nil, backup: true, with_exe: true, &block)
285
- url = sdk_url_for_platform(version: version) if url.nil? || url.eql?('DEF')
286
- folder = Products::Transferd.sdk_directory if folder.nil?
287
- subfolder_lambda = block
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: delete old archives ?
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 = subfolder_lambda.call(entry_name)
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
- return unless with_exe
320
- # Ensure necessary files are there, or generate them
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
- sdk_ascp_version = get_ascp_version(path(:ascp))
328
- transferd_version = get_exe_version(path(:transferd), 'version')
329
- sdk_name = 'IBM Aspera Transfer SDK'
330
- sdk_version = transferd_version || sdk_ascp_version
331
- File.write(File.join(folder, Products::Other::INFO_META_FILE), "<product><name>#{sdk_name}</name><version>#{sdk_version}</version></product>")
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 `block`
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
- # @param block [Proc] Produces a string that describes the problem for complex messages
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
- # @param block [Proc] Additional description in front of message
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
- # @param block [Proc] Additional description in front of message
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: 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
- # @param block [Proc] Additional description in front of message
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: type)
76
- assert_array_all(hash.keys, key_class, type: AssertError){"#{"#{yield}: " if block_given?}keys"} unless key_class.nil?
77
- assert_array_all(hash.values, value_class, type: AssertError){"#{"#{yield}: " if block_given?}values"} unless value_class.nil?
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
- # @param block [Proc] Additional description in front of message
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
- # @param &block [Proc] Additional description in front of message
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..-1].split(i[0])},
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 [Object] Evaluated value
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?