aspera-cli 4.25.5 → 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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +87 -54
  4. data/CONTRIBUTING.md +11 -2
  5. data/lib/aspera/api/aoc.rb +118 -78
  6. data/lib/aspera/api/node.rb +126 -61
  7. data/lib/aspera/ascp/installation.rb +117 -54
  8. data/lib/aspera/cli/extended_value.rb +1 -0
  9. data/lib/aspera/cli/formatter.rb +47 -40
  10. data/lib/aspera/cli/manager.rb +30 -4
  11. data/lib/aspera/cli/plugins/aoc.rb +214 -136
  12. data/lib/aspera/cli/plugins/ats.rb +3 -3
  13. data/lib/aspera/cli/plugins/base.rb +17 -42
  14. data/lib/aspera/cli/plugins/config.rb +5 -3
  15. data/lib/aspera/cli/plugins/console.rb +3 -3
  16. data/lib/aspera/cli/plugins/faspex.rb +5 -5
  17. data/lib/aspera/cli/plugins/faspex5.rb +20 -18
  18. data/lib/aspera/cli/plugins/node.rb +66 -70
  19. data/lib/aspera/cli/plugins/oauth.rb +5 -12
  20. data/lib/aspera/cli/plugins/orchestrator.rb +13 -13
  21. data/lib/aspera/cli/plugins/preview.rb +116 -80
  22. data/lib/aspera/cli/plugins/server.rb +3 -11
  23. data/lib/aspera/cli/plugins/shares.rb +7 -7
  24. data/lib/aspera/cli/version.rb +1 -1
  25. data/lib/aspera/dot_container.rb +7 -3
  26. data/lib/aspera/environment.rb +30 -19
  27. data/lib/aspera/keychain/macos_security.rb +1 -1
  28. data/lib/aspera/log.rb +1 -1
  29. data/lib/aspera/preview/file_types.rb +1 -1
  30. data/lib/aspera/preview/generator.rb +146 -91
  31. data/lib/aspera/preview/options.rb +4 -1
  32. data/lib/aspera/preview/terminal.rb +50 -20
  33. data/lib/aspera/preview/utils.rb +76 -34
  34. data/lib/aspera/products/transferd.rb +1 -1
  35. data/lib/aspera/rest.rb +32 -0
  36. data/lib/aspera/rest_list.rb +23 -16
  37. data/lib/aspera/secret_hider.rb +3 -1
  38. data/lib/aspera/sync/operations.rb +1 -1
  39. data/lib/aspera/uri_reader.rb +17 -2
  40. data.tar.gz.sig +0 -0
  41. metadata +5 -5
  42. metadata.gz.sig +0 -0
@@ -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 [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']
@@ -530,37 +553,79 @@ module Aspera
530
553
  Aspera.assert_type(query, Hash, NilClass){'query'}
531
554
  Aspera.assert(!call_args.key?(:query))
532
555
  query = {} if query.nil?
533
- query[:iteration_token] = iteration[0] unless iteration.nil?
556
+ query[:iteration_token] = iteration[0] unless iteration.nil? || iteration[0].nil?
534
557
  max = query.delete(RestList::MAX_ITEMS)
558
+ # Return empty list immediately if max is 0
559
+ return [] if max&.zero?
535
560
  item_list = []
536
561
  loop do
537
562
  data, http = read(subpath, query, **call_args, ret: :both)
538
563
  Aspera.assert_type(data, Array){"Expected data to be an Array, got: #{data.class}"}
539
564
  # no data
540
565
  break if data.empty?
541
- # get next iteration token from link
542
- next_iteration_token = nil
543
- link_info = http['Link']
544
- unless link_info.nil?
545
- m = link_info.match(/<([^>]+)>/)
546
- Aspera.assert(m){"Cannot parse iteration in Link: #{link_info}"}
547
- next_iteration_token = Rest.query_to_h(URI.parse(m[1]).query)['iteration_token']
548
- end
549
- # same as last iteration: stop
550
- break if next_iteration_token&.eql?(query[:iteration_token])
551
- query[:iteration_token] = next_iteration_token
552
566
  item_list.concat(data)
567
+ # Check if we reached the max limit
553
568
  if max&.<=(item_list.length)
554
569
  item_list = item_list.slice(0, max)
555
570
  break
556
571
  end
572
+ # Update progress spinner
573
+ RestParameters.instance.spinner_cb.call(item_list.length)
574
+ # Parse Link header according to RFC 8288 to extract next iteration token
575
+ next_url = Rest.parse_link_header(http['Link'], rel: 'next')
576
+ next_iteration_token = nil
577
+ if next_url
578
+ begin
579
+ parsed_uri = URI.parse(next_url)
580
+ query_params = Rest.query_to_h(parsed_uri.query) if parsed_uri.query
581
+ next_iteration_token = query_params['iteration_token'] if query_params
582
+ rescue URI::InvalidURIError => e
583
+ Log.log.warn{"Invalid URI in Link header: #{next_url} - #{e.message}"}
584
+ end
585
+ end
586
+ # Stop if no next token
557
587
  break if next_iteration_token.nil?
588
+ # Stop if same token as current (infinite loop protection)
589
+ break if next_iteration_token.eql?(query[:iteration_token])
590
+ # Update token for next iteration
591
+ query[:iteration_token] = next_iteration_token
558
592
  end
593
+ # Signal completion
594
+ RestParameters.instance.spinner_cb.call(action: :success)
559
595
  # save iteration token if needed
560
596
  iteration[0] = query[:iteration_token] unless iteration.nil?
561
597
  item_list
562
598
  end
563
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
+
564
629
  private
565
630
 
566
631
  # Method called in loop for each entry for `resolve_api_fid`
@@ -577,12 +642,12 @@ module Aspera
577
642
  raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless path_fully_consumed
578
643
  # It's terminal, we found it
579
644
  Log.log.debug{"process_api_fid: found #{path} -> #{entry['id']}"}
580
- state[:result] = {api: self, file_id: entry['id']}
645
+ state[:result] = NodeFileId.new(self, entry['id'])
581
646
  return false
582
647
  when 'folder'
583
648
  if path_fully_consumed
584
649
  # We found it
585
- state[:result] = {api: self, file_id: entry['id']}
650
+ state[:result] = NodeFileId.new(self, entry['id'])
586
651
  return false
587
652
  end
588
653
  when 'link'
@@ -592,10 +657,10 @@ module Aspera
592
657
  other_node = nil
593
658
  other_node = node_id_to_node(entry['target_node_id']) if entry_has_link_information(entry)
594
659
  raise Error, 'Cannot resolve link' if other_node.nil?
595
- state[:result] = {api: other_node, file_id: entry['target_id']}
660
+ state[:result] = NodeFileId.new(other_node, entry['target_id'])
596
661
  else
597
662
  # We found it but we do not process the link
598
- state[:result] = {api: self, file_id: entry['id']}
663
+ state[:result] = NodeFileId.new(self, entry['id'])
599
664
  end
600
665
  return false
601
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
- return
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
@@ -178,33 +179,32 @@ module Aspera
178
179
  # Folder, PVCL, version, license information
179
180
  def ascp_info_from_log
180
181
  data = {}
182
+ _, stderr, status = Environment.secure_execute(path(:ascp), '-DDL-', mode: :capture, exception: false)
181
183
  # read PATHs from ascp directly, and pvcl modules as well
182
- Open3.popen3(path(:ascp), '-DDL-') do |_stdin, _stdout, stderr, thread|
183
- last_line = ''
184
- while (line = stderr.gets)
185
- line.chomp!
186
- # skip lines that may have accents
187
- next unless line.valid_encoding?
188
- last_line = line
189
- case line
190
- when /^DBG Path ([^ ]+) (dir|file) +: (.*)$/
191
- data[Regexp.last_match(1)] = Regexp.last_match(3)
192
- when /^DBG Added module group:"(?<module>[^"]+)" name:"(?<scheme>[^"]+)", version:"(?<version>[^"]+)" interface:"(?<interface>[^"]+)"$/
193
- c = Regexp.last_match.named_captures.symbolize_keys
194
- data[c[:interface]] ||= {}
195
- data[c[:interface]][c[:module]] ||= []
196
- data[c[:interface]][c[:module]].push("#{c[:scheme]} v#{c[:version]}")
197
- when %r{^DBG License result \(/license/(\S+)\): (.+)$}
198
- data[Regexp.last_match(1)] = Regexp.last_match(2)
199
- when /^LOG (.+) version ([0-9.]+)$/
200
- data['product_name'] = Regexp.last_match(1)
201
- data['product_version'] = Regexp.last_match(2)
202
- when /^LOG Initializing FASP version ([^,]+),/
203
- data['ascp_version'] = Regexp.last_match(1)
204
- end
184
+ last_line = ''
185
+ stderr.lines do |line|
186
+ line.chomp!
187
+ # Skip lines that may have accents
188
+ next unless line.valid_encoding?
189
+ last_line = line
190
+ case line
191
+ when /^DBG Path ([^ ]+) (dir|file) +: (.*)$/
192
+ data[Regexp.last_match(1)] = Regexp.last_match(3)
193
+ when /^DBG Added module group:"(?<module>[^"]+)" name:"(?<scheme>[^"]+)", version:"(?<version>[^"]+)" interface:"(?<interface>[^"]+)"$/
194
+ c = Regexp.last_match.named_captures.symbolize_keys
195
+ data[c[:interface]] ||= {}
196
+ data[c[:interface]][c[:module]] ||= []
197
+ data[c[:interface]][c[:module]].push("#{c[:scheme]} v#{c[:version]}")
198
+ when %r{^DBG License result \(/license/(\S+)\): (.+)$}
199
+ data[Regexp.last_match(1)] = Regexp.last_match(2)
200
+ when /^LOG (.+) version ([0-9.]+)$/
201
+ data['product_name'] = Regexp.last_match(1)
202
+ data['product_version'] = Regexp.last_match(2)
203
+ when /^LOG Initializing FASP version ([^,]+),/
204
+ data['ascp_version'] = Regexp.last_match(1)
205
205
  end
206
- raise last_line if !thread.value.exitstatus.eql?(1) && !data.key?('root')
207
206
  end
207
+ raise last_line if !status.exitstatus.eql?(1) && !data.key?('root')
208
208
  return data
209
209
  end
210
210
 
@@ -242,7 +242,8 @@ module Aspera
242
242
  return info.first['url']
243
243
  end
244
244
 
245
- # @param &block called with entry information
245
+ # @param sdk_archive_path [String] path to SDK archive
246
+ # @param &block called with: file path, data stream, link target if link?
246
247
  def extract_archive_files(sdk_archive_path)
247
248
  Aspera.assert(block_given?){'missing block'}
248
249
  case sdk_archive_path
@@ -276,32 +277,33 @@ module Aspera
276
277
  end
277
278
 
278
279
  # Retrieves ascp binary for current system architecture from URL or file
279
- # @param url [String] URL to SDK archive, or SpecialValues::DEF
280
- # @param folder [String] Destination folder path
281
- # @param backup [Boolean] If destination folder exists, then rename
282
- # @param with_exe [Boolean] If false, only retrieves files, but do not generate or restrict access
283
- # @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
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
284
285
  # @return [Array] name, ascp version (from execution), folder
285
- def install_sdk(url: nil, version: nil, folder: nil, backup: true, with_exe: true, &block)
286
- url = sdk_url_for_platform(version: version) if url.nil? || url.eql?('DEF')
287
- folder = Products::Transferd.sdk_directory if folder.nil?
288
- subfolder_lambda = block
289
- if subfolder_lambda.nil?
290
- # default files to extract directly to main folder if in selected source folders
291
- subfolder_lambda = ->(name) do
292
- Products::Transferd::RUNTIME_FOLDERS.any?{ |i| name.match?(%r{^[^/]*/#{i}/})} ? '/' : nil
293
- end
294
- end
295
- FileUtils.mkdir_p(folder)
296
- # rename old install
297
- 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)
298
290
  Log.log.warn('Previous install exists, renaming folder.')
299
291
  File.rename(folder, "#{folder}.#{Time.now.strftime('%Y%m%d%H%M%S')}")
300
- # TODO: delete old archives ?
292
+ # TODO: cleanup old archives ?
301
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)
302
299
  sdk_archive_path = UriReader.read_as_file(url)
303
300
  extract_archive_files(sdk_archive_path) do |entry_name, entry_stream, link_target|
304
- dest_folder = subfolder_lambda.call(entry_name)
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
305
307
  next if dest_folder.nil?
306
308
  dest_folder = File.join(folder, dest_folder)
307
309
  if dest_folder.end_with?('/')
@@ -310,26 +312,86 @@ module Aspera
310
312
  dest_file = dest_folder
311
313
  dest_folder = File.dirname(dest_file)
312
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
313
322
  FileUtils.mkdir_p(dest_folder)
314
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
315
346
  File.open(dest_file, 'wb'){ |output_stream| IO.copy_stream(entry_stream, output_stream)}
316
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
317
363
  File.symlink(link_target, dest_file)
318
364
  end
319
365
  end
320
- return unless with_exe
321
- # Ensure necessary files are there, or generate them
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
322
375
  SDK_FILES.each do |file_id_sym|
323
376
  file_path = path(file_id_sym)
324
377
  if file_path && EXE_FILES.include?(file_id_sym)
325
378
  Environment.restrict_file_access(file_path, mode: 0o755) if File.exist?(file_path)
326
379
  end
327
380
  end
328
- sdk_ascp_version = get_ascp_version(path(:ascp))
329
- transferd_version = get_exe_version(path(:transferd), 'version')
330
- sdk_name = 'IBM Aspera Transfer SDK'
331
- sdk_version = transferd_version || sdk_ascp_version
332
- File.write(File.join(folder, Products::Other::INFO_META_FILE), "<product><name>#{sdk_name}</name><version>#{sdk_version}</version></product>")
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
333
395
  return sdk_name, sdk_version, folder
334
396
  end
335
397
 
@@ -350,6 +412,7 @@ module Aspera
350
412
  END_OF_CONFIG_FILE
351
413
  # all executable files from SDK
352
414
  EXE_FILES = %i[ascp ascp4 async transferd].freeze
415
+ # IDs of files present in SDK
353
416
  SDK_FILES = %i[ssh_private_dsa ssh_private_rsa aspera_license aspera_conf fallback_certificate fallback_private_key].unshift(*EXE_FILES).freeze
354
417
  TRANSFERD_ARCHIVE_LOCATION_URL = 'https://ibm.biz/sdk_location'
355
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)},