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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +74 -47
  4. data/CONTRIBUTING.md +1 -1
  5. data/lib/aspera/api/aoc.rb +118 -78
  6. data/lib/aspera/api/node.rb +101 -49
  7. data/lib/aspera/ascp/installation.rb +94 -30
  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 +2 -10
  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 +3 -2
  27. data/lib/aspera/log.rb +1 -1
  28. data/lib/aspera/preview/file_types.rb +1 -1
  29. data/lib/aspera/preview/generator.rb +146 -91
  30. data/lib/aspera/preview/options.rb +4 -1
  31. data/lib/aspera/preview/terminal.rb +50 -20
  32. data/lib/aspera/preview/utils.rb +76 -34
  33. data/lib/aspera/products/transferd.rb +1 -1
  34. data/lib/aspera/rest.rb +1 -0
  35. data/lib/aspera/rest_list.rb +23 -16
  36. data/lib/aspera/secret_hider.rb +3 -1
  37. data/lib/aspera/uri_reader.rb +17 -2
  38. data.tar.gz.sig +0 -0
  39. metadata +5 -5
  40. 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']
@@ -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)
@@ -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
@@ -241,7 +242,8 @@ 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
+ # @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 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
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, 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)
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: delete old archives ?
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 = 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
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
- return unless with_exe
320
- # 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
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
- 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>")
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)},