aspera-cli 4.26.1 → 4.27.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 (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -25,9 +25,9 @@ module Aspera
25
25
  end
26
26
 
27
27
  # Called by user of progress bar with a status on a transfer session
28
- # @param session_id the unique identifier of a transfer session
28
+ # @param session_id [String] the unique identifier of a transfer session
29
29
  # @param type [Symbol] one of: sessions_init, session_start, session_size, transfer, session_end and end
30
- # @param info optional specific additional info for the given event type
30
+ # @param info [Object, nil] optional specific additional info for the given event type
31
31
  def event(type, session_id: nil, info: nil)
32
32
  Log.log.trace1{"progress: #{type} #{session_id} #{info}"}
33
33
  return if @completed
@@ -43,14 +43,14 @@ module Aspera
43
43
  case type
44
44
  when :sessions_init
45
45
  # Give opportunity to show progress of initialization with multiple status
46
- Aspera.assert(session_id.nil?)
46
+ Aspera.assert(session_id.nil?, 'session_id must be nil for :sessions_init event')
47
47
  Aspera.assert_type(info, String)
48
48
  # Initialization of progress bar
49
49
  @title = info
50
50
  when :session_start
51
51
  Aspera.assert_type(session_id, String)
52
- Aspera.assert(info.nil?)
53
- raise "Session #{session_id} already started" if @sessions[session_id]
52
+ Aspera.assert(info.nil?, 'info must be nil for :session_start event')
53
+ Aspera.assert(!@sessions[session_id]){"Session #{session_id} already started"}
54
54
  @sessions[session_id] = {
55
55
  job_size: 0, # Total size of transfer (pre-calc)
56
56
  current: 0,
@@ -60,7 +60,7 @@ module Aspera
60
60
  @title = nil
61
61
  when :session_size
62
62
  Aspera.assert_type(session_id, String)
63
- Aspera.assert(!info.nil?)
63
+ Aspera.assert(!info.nil?, 'info must not be nil for :session_size event')
64
64
  Aspera.assert_type(@sessions[session_id], Hash)
65
65
  @sessions[session_id][:job_size] = info.to_i
66
66
  sessions_total = total(:job_size)
@@ -76,12 +76,12 @@ module Aspera
76
76
  end
77
77
  when :session_end
78
78
  Aspera.assert_type(session_id, String)
79
- Aspera.assert(info.nil?)
79
+ Aspera.assert(info.nil?, 'info must be nil for :session_end event')
80
80
  # A session may be too short and finish before it has been started
81
81
  @sessions[session_id][:running] = false if @sessions[session_id].is_a?(Hash)
82
82
  when :end
83
- Aspera.assert(session_id.nil?)
84
- Aspera.assert(info.nil?)
83
+ Aspera.assert(session_id.nil?, 'session_id must be nil for :end event')
84
+ Aspera.assert(info.nil?, 'info must be nil for :end event')
85
85
  @progress_bar.finish
86
86
  else Aspera.error_unexpected_value(type){'event type'}
87
87
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/error'
4
+ require 'aspera/log'
5
+ require 'aspera/assert'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Mixin providing vault/keychain functionality to Plugin::Config.
10
+ # Depends on `options` and `context.main_folder` being available in the including class.
11
+ module VaultManager
12
+ def action_vault_show(label:, **)
13
+ Result::SingleObject.new(vault.get(label: label))
14
+ end
15
+
16
+ def action_vault_create(info:, **)
17
+ vault.set(info.symbolize_keys)
18
+ Result::Status.new('Secret added')
19
+ end
20
+
21
+ def action_vault_delete(label:, **)
22
+ vault.delete(label: label)
23
+ Result::Status.new("Secret deleted: #{label}")
24
+ end
25
+
26
+ def action_vault_password(new_password:, **)
27
+ Aspera.assert(vault.respond_to?(:change_password), 'Vault does not support password change')
28
+ vault.change_password(new_password)
29
+ Result::Status.new('Vault password updated')
30
+ end
31
+
32
+ # @return [String] value from vault matching <name>.<param>
33
+ def vault_value(name)
34
+ m = name.split('.')
35
+ Aspera.assert(m.length.eql?(2), type: BadArgument){'vault name shall match <name>.<param>'}
36
+ info = vault.get(label: m[0])
37
+ value = info[m[1].to_sym]
38
+ raise "no such entry value: #{m[1]}" if value.nil?
39
+ return value
40
+ end
41
+
42
+ # @return [Keychain::Base] vault instance, lazily created from options
43
+ def vault
44
+ return @vault_instance unless @vault_instance.nil?
45
+ info = options.get_option(:vault, mandatory: true).symbolize_keys
46
+ info[:type] ||= 'file'
47
+ require 'aspera/keychain/factory'
48
+ @vault_instance = Keychain::Factory.create(
49
+ info,
50
+ Info::CMD_NAME,
51
+ context.main_folder,
52
+ options.get_option(:vault_password)
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
@@ -4,6 +4,6 @@ module Aspera
4
4
  module Cli
5
5
  # For beta add extension : .beta1
6
6
  # For dev version add extension : .pre
7
- VERSION = '4.26.1'
7
+ VERSION = '4.27.0'
8
8
  end
9
9
  end
@@ -10,19 +10,25 @@ module Aspera
10
10
  class Wizard
11
11
  WIZARD_RESULT_KEYS = %i[preset_value test_args].freeze
12
12
  DEFAULT_PRIV_KEY_FILENAME = 'my_private_key.pem' # pragma: allowlist secret
13
- private_constant :WIZARD_RESULT_KEYS,
14
- :DEFAULT_PRIV_KEY_FILENAME
13
+ private_constant :WIZARD_RESULT_KEYS, :DEFAULT_PRIV_KEY_FILENAME
14
+
15
+ class << self
16
+ # Declare all wizard CLI options (metadata only - no handler binding yet).
17
+ # @param options [Aspera::Cli::Parser]
18
+ def declare_options(options)
19
+ options.declare(:override, description: 'Wizard: override existing value', allowed: Allowed::TYPES_BOOLEAN, default: false)
20
+ options.declare(:default, description: 'Wizard: set as default configuration for specified plugin (also: update)', allowed: Allowed::TYPES_BOOLEAN, default: true)
21
+ options.declare(:key_path, description: 'Wizard: path to private key for JWT')
22
+ end
23
+ end
15
24
 
16
25
  def initialize(parent, main_folder)
17
26
  @parent = parent
18
27
  @main_folder = main_folder
19
- # Wizard options
20
- options.declare(:override, 'Wizard: override existing value', allowed: Allowed::TYPES_BOOLEAN, default: false)
21
- options.declare(:default, 'Wizard: set as default configuration for specified plugin (also: update)', allowed: Allowed::TYPES_BOOLEAN, default: true)
22
- options.declare(:key_path, 'Wizard: path to private key for JWT')
28
+ self.class.declare_options(options)
23
29
  end
24
30
 
25
- # @return false if in test mode to avoid interactive input
31
+ # @return [Boolean] false if in test mode to avoid interactive input
26
32
  def required
27
33
  !ENV['ASCLI_WIZ_TEST']
28
34
  end
@@ -35,20 +41,15 @@ module Aspera
35
41
  @parent.formatter
36
42
  end
37
43
 
38
- def config
39
- @parent.config
40
- end
41
-
42
44
  def check_email(email)
43
45
  Aspera.assert(email =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i, type: ParameterError){"Username shall be an email: #{email}"}
44
46
  end
45
47
 
46
48
  # Find a plugin, and issue the "require"
47
49
  # @return [Hash] plugin info: { product:, name:, url:, version: }
48
- def identify_plugins_for_url
49
- app_url = options.get_next_argument('url', mandatory: true)
50
- check_only = options.get_next_argument('plugin name', mandatory: false)
51
- check_only = check_only.to_sym unless check_only.nil?
50
+ def identify_plugins_for_url(url:, plugin_name: nil)
51
+ app_url = url
52
+ check_only = plugin_name&.to_sym
52
53
  found_apps = []
53
54
  my_self_plugin_sym = self.class.name.split('::').last.downcase.to_sym
54
55
  Plugins::Factory.instance.plugin_list.each do |plugin_name_sym|
@@ -74,13 +75,13 @@ module Aspera
74
75
  next if detection_info.nil?
75
76
  Aspera.assert_type(detection_info, Hash)
76
77
  Aspera.assert_type(detection_info[:url], String) if detection_info.key?(:url)
77
- app_name = plugin_klass.respond_to?(:application_name) ? plugin_klass.application_name : plugin_klass.name.split('::').last
78
+ app_name = plugin_klass.application_name
78
79
  # If there is a redirect, then the detector can override the url.
79
80
  found_apps.push({product: plugin_name_sym, name: app_name, url: app_url, version: 'unknown'}.merge(detection_info))
80
81
  end
81
82
  RestParameters.instance.spinner_cb.call(action: :success)
82
- raise "No known application found at #{app_url}" if found_apps.empty?
83
- Aspera.assert(found_apps.all?{ |a| a.keys.all?(Symbol)})
83
+ Aspera.assert(!found_apps.empty?){"No known application found at #{app_url}"}
84
+ Aspera.assert(found_apps.all?{ |a| a.keys.all?(Symbol)}, 'all app info keys must be symbols')
84
85
  return found_apps
85
86
  end
86
87
 
@@ -122,17 +123,18 @@ module Aspera
122
123
 
123
124
  # Wizard function, creates configuration
124
125
  # @param apps [Array] list of detected apps
125
- def find(apps)
126
+ def find(apps, preset_name: '')
126
127
  identification = if apps.length.eql?(1)
127
128
  Log.log.debug{"Detected: #{identification}"}
128
129
  apps.first
129
130
  else
130
131
  formatter.display_status('Multiple applications detected, please select from:')
131
- formatter.display_results(type: :object_list, data: apps, fields: %w[product url version])
132
+ require 'aspera/cli/result'
133
+ formatter.display_results(Result::ObjectList.new(apps, fields: %w[product url version]))
132
134
  answer = options.prompt_user_input_in_list('product', apps.map{ |a| a[:product]})
133
135
  apps.find{ |a| a[:product].eql?(answer)}
134
136
  end
135
- wiz_preset_name = options.get_next_argument('preset name', default: '')
137
+ wiz_preset_name = preset_name
136
138
  Log.dump(:identification, identification)
137
139
  wiz_url = identification[:url]
138
140
  formatter.display_status("Using: #{identification[:name]} at #{wiz_url}".bold)
@@ -140,9 +142,7 @@ module Aspera
140
142
  options.add_option_preset({url: wiz_url}, 'wizard')
141
143
  # Instantiate plugin: command line options will be known, e.g. private_key, and wizard can be called
142
144
  plugin_instance = Plugins::Factory.instance.plugin_class(identification[:product]).new(context: @parent.context)
143
- Aspera.assert(plugin_instance.respond_to?(:wizard), type: Cli::BadArgument) do
144
- "Detected: #{identification[:product]}, but this application has no wizard"
145
- end
145
+ Aspera.assert(plugin_instance.respond_to?(:wizard), type: Cli::BadArgument){"Detected: #{identification[:product]}, but this application has no wizard"}
146
146
  # Call the wizard
147
147
  wizard_result = plugin_instance.wizard(self, wiz_url)
148
148
  Log.log.debug{"wizard result: #{wizard_result}"}
@@ -161,12 +161,12 @@ module Aspera
161
161
  # Init defaults if necessary
162
162
  option_override = options.get_option(:override, mandatory: true)
163
163
  option_default = options.get_option(:default, mandatory: true)
164
- config.defaults_set(identification[:product], wiz_preset_name, wizard_result[:preset_value].stringify_keys, option_default, option_override)
164
+ @parent.context.presets.defaults_set(identification[:product], wiz_preset_name, wizard_result[:preset_value].stringify_keys, option_default, option_override)
165
165
  test_args = wizard_result[:test_args]
166
166
  test_args = "-P#{wiz_preset_name} #{test_args}" unless option_default
167
167
  # TODO: actually test the command
168
168
  test_cmd = "#{Info::CMD_NAME} #{identification[:product]} #{test_args}"
169
- return Main.result_status("You can test with:\n#{test_cmd.red}")
169
+ return Result::Status.new("You can test with:\n#{test_cmd.red}")
170
170
  end
171
171
  end
172
172
  end
@@ -3,12 +3,13 @@
3
3
  require 'aspera/log'
4
4
  require 'aspera/assert'
5
5
  require 'aspera/schema/registry'
6
+ require 'aspera/exec_spec'
6
7
  require 'yaml'
7
8
  module Aspera
8
9
  # Helper class to build command line from a parameter list (key-value hash)
9
10
  # Constructor takes hash: `{ 'param1':'value1', ...}`
10
11
  # `process_param` is called repeatedly with all known parameters
11
- # `add_env_args` is called to get resulting param list and env var (also checks that all params were used)
12
+ # `add_to_exec_spec` is called to get resulting param list and env var (also checks that all params were used)
12
13
  class CommandLineBuilder
13
14
  # Supported keys in JSON schema
14
15
  PROPERTY_KEYS = [
@@ -22,6 +23,7 @@ module Aspera
22
23
  '$comment', # [String]
23
24
  'x-cli-envvar', # [String] Name of env var
24
25
  'x-cli-option', # [String] Command line option (starts with "-")
26
+ 'x-cli-false', # [String]. Option to use when switch value is false
25
27
  'x-cli-short', # [String] Command line option (starts with "-")
26
28
  'x-cli-switch', # [Boolean] `true` if option has no arg, else by default option has a value
27
29
  'x-cli-special', # [Boolean] `true` if special handling (deferred)
@@ -36,7 +38,9 @@ module Aspera
36
38
 
37
39
  class << self
38
40
  # Called by provider of definition before constructor of this class so that schema has all mandatory fields
39
- # @return [Aspera::Schema::Reader]
41
+ # @param name_sym [Symbol] name of the schema to read
42
+ # @param ascp [Boolean] `true` if `ascp` mode
43
+ # @return [Aspera::Schema::Reader] validated schema reader
40
44
  def read_schema(name_sym, ascp: false)
41
45
  validate_schema(Schema::Registry.instance.reader(name_sym), ascp: ascp)
42
46
  end
@@ -50,6 +54,7 @@ module Aspera
50
54
 
51
55
  private
52
56
 
57
+ # `ascp`: mandatory type
53
58
  DIRECT_PROPERTIES = %w[x-cli-option x-cli-envvar x-cli-special].freeze
54
59
 
55
60
  # Fill default values for some fields in the schema
@@ -66,7 +71,7 @@ module Aspera
66
71
  Aspera.assert(node.key?('type') || node.key?('enum')){"Missing type for #{name} in #{schema.current.dig('description').current}"}
67
72
  Aspera.assert(node['type'].eql?('boolean')){"switch must be bool: #{name}"} if node['x-cli-switch'] && !node['x-cli-special']
68
73
  node['x-cli-option'] = "--#{name.to_s.tr('_', '-')}" if node['x-cli-option'].eql?(true) || (node['x-cli-switch'].eql?(true) && !node.key?('x-cli-option'))
69
- Aspera.assert(DIRECT_PROPERTIES.any?{ |i| node.key?(i)}, type: :warn){name} if ascp && supported_by_agent(:direct, node)
74
+ Aspera.assert(DIRECT_PROPERTIES.any?{ |i| node.key?(i)}, name, type: :warn) if ascp && supported_by_agent(:direct, node)
70
75
  node.freeze
71
76
  end
72
77
  schema
@@ -80,10 +85,7 @@ module Aspera
80
85
  @object = object # keep reference so that it can be modified by caller before calling `process_params`
81
86
  @schema = schema
82
87
  @convert = convert
83
- @result = {
84
- env: {},
85
- args: []
86
- }
88
+ @result = ExecSpec.new
87
89
  @processed_parameters = []
88
90
  end
89
91
 
@@ -97,16 +99,16 @@ module Aspera
97
99
  end
98
100
 
99
101
  # Add processed parameters to env and args, warns about unused parameters
100
- # @param [Hash] env_args with :env and :args
101
- def add_env_args(env_args)
102
- Log.dump(:env_args, @result)
102
+ # @param exec_spec [ExecSpec]
103
+ def add_to_exec_spec(exec_spec)
104
+ Log.dump(:exec_spec, @result)
103
105
  # warn about non translated arguments
104
106
  @object.each_pair do |name, value|
105
107
  Log.log.warn{"Unknown transfer spec parameter: #{name} = \"#{value}\""} unless @processed_parameters.include?(name)
106
108
  end
107
109
  # set result
108
- env_args[:env].merge!(@result[:env])
109
- env_args[:args].concat(@result[:args])
110
+ exec_spec.env.merge!(@result.env)
111
+ exec_spec.args.concat(@result.args)
110
112
  return
111
113
  end
112
114
 
@@ -114,7 +116,7 @@ module Aspera
114
116
  def add_command_line_options(*options)
115
117
  options = options.first if options.first.is_a?(Array) && options.length.eql?(1)
116
118
  Aspera.assert_type(options, Array)
117
- options.each{ |o| @result[:args].push(o.to_s)}
119
+ options.each{ |o| @result.args.push(o.to_s)}
118
120
  end
119
121
 
120
122
  def process_params
@@ -140,7 +142,7 @@ module Aspera
140
142
  return
141
143
  end
142
144
  # check mandatory parameter (nil is valid value), TODO: change exception ?
143
- raise Transfer::Error, "Missing mandatory parameter: #{name}" if @schema['required']&.include?(name) && !properties['x-cli-special'] && !@object.key?(name)
145
+ Aspera.assert(!(@schema['required']&.include?(name) && !properties['x-cli-special'] && !@object.key?(name)), type: Transfer::Error){"Missing mandatory parameter: #{name}"}
144
146
  parameter_value = @object[name]
145
147
  # no default setting
146
148
  # parameter_value=properties['default'] if parameter_value.nil? and properties.has_key?('default')
@@ -166,7 +168,7 @@ module Aspera
166
168
  return if parameter_value.nil?
167
169
 
168
170
  # check that value is of an accepted type (string, integer, boolean)
169
- raise "Enum value #{parameter_value} is not allowed for #{name}" if properties.key?('enum') && !properties['enum'].include?(parameter_value)
171
+ Aspera.assert(!(properties.key?('enum') && !properties['enum'].include?(parameter_value))){"Enum value #{parameter_value} is not allowed for #{name}"}
170
172
 
171
173
  # convert some values if value on command line needs processing from value in structure
172
174
  if (convert = properties['x-cli-convert'])
@@ -176,7 +178,7 @@ module Aspera
176
178
  when String then @convert.send(convert, parameter_value)
177
179
  else Aspera.error_unexpected_value(convert){"Conversion type for #{name} is Hash or String only."}
178
180
  end
179
- raise "No conversion for: #{name}=#{parameter_value}" if converted_value.nil?
181
+ Aspera.assert(!converted_value.nil?){"No conversion for: #{name}=#{parameter_value}"}
180
182
  parameter_value = converted_value
181
183
  end
182
184
 
@@ -190,17 +192,15 @@ module Aspera
190
192
  return
191
193
  elsif properties.key?('x-cli-envvar')
192
194
  # set in env var
193
- @result[:env][properties['x-cli-envvar']] = parameter_value
195
+ @result.env[properties['x-cli-envvar']] = parameter_value
194
196
  elsif properties['x-cli-switch']
195
- # if present and true : just add option without value
196
- add_param = false
197
197
  case parameter_value
198
- when false then nil # nothing to put on command line, no creation by default
199
- when true then add_param = true
198
+ when true
199
+ add_command_line_options(properties['x-cli-option'])
200
+ when false
201
+ add_command_line_options(properties['x-cli-false']) if properties.key?('x-cli-false')
200
202
  else Aspera.error_unexpected_value(parameter_value){name}
201
203
  end
202
- # add_param = !add_param if properties[:add_on_false]
203
- add_command_line_options(properties['x-cli-option']) if add_param
204
204
  else
205
205
  # transform into command line option with value
206
206
  # parameter_value=parameter_value.to_s if parameter_value.is_a?(Integer)
@@ -3,9 +3,14 @@
3
3
  require 'aspera/assert'
4
4
  require 'singleton'
5
5
  require 'openssl'
6
+ require 'zlib'
6
7
 
7
8
  module Aspera
8
9
  # a simple binary data repository
10
+ #
11
+ # @!method self.instance
12
+ # Returns the singleton instance of DataRepository
13
+ # @return [DataRepository] the singleton instance
9
14
  class DataRepository
10
15
  include Singleton
11
16
 
@@ -19,7 +24,7 @@ module Aspera
19
24
  # @return [String] decoded data
20
25
  def item(name)
21
26
  index = ELEMENTS.index(name)
22
- raise ParameterError, "Unknown data item #{name} (#{name.class})" unless index
27
+ Aspera.assert(index, type: ParameterError){"Unknown data item #{name} (#{name.class})"}
23
28
  raw_data = data(START_INDEX + index)
24
29
  case name
25
30
  when :dsa, :rsa
@@ -39,7 +39,7 @@ module Aspera
39
39
  # @param container [Hash, Array]
40
40
  # @param index [String, Integer]
41
41
  def array_requires_integer_index!(container, index)
42
- Aspera.assert(container.is_a?(Hash) || index.is_a?(Integer)){'Using String index when Integer index used previously'}
42
+ Aspera.assert(container.is_a?(Hash) || index.is_a?(Integer), 'Using String index when Integer index used previously')
43
43
  end
44
44
 
45
45
  # Create a new `Hash` or `Array` depending on type of `key`
@@ -48,7 +48,7 @@ module Aspera
48
48
  end
49
49
  end
50
50
 
51
- # @param [Hash,Array] Container object
51
+ # @param container [Hash,Array] Container object
52
52
  def initialize(container)
53
53
  Aspera.assert_type(container, Hash)
54
54
  # tail (pop,push) contains the next element to display
@@ -72,13 +72,13 @@ module Aspera
72
72
  when Hash
73
73
  add_elements(path, current)
74
74
  when Array
75
- # Array has no nested structures -> list of Strings
75
+ # Array has no nested structures: list of Strings
76
76
  if current.none?{ |i| i.is_a?(Array) || i.is_a?(Hash)}
77
77
  to_insert = current.map(&:to_s)
78
- # Array of Hashes with only 'name' keys -> list of Strings
78
+ # Array of Hashes with only 'name' keys: list of Strings
79
79
  elsif current.all?{ |i| i.is_a?(Hash) && i.keys == ['name']}
80
80
  to_insert = current.map{ |i| i['name']}
81
- # Array of Hashes with only 'name' and 'value' keys -> Hash of key/values
81
+ # Array of Hashes with only 'name' and 'value' keys: Hash of key/values
82
82
  elsif current.all?{ |i| i.is_a?(Hash) && i.key?('name') && i.key?('value') && i.length <= 3}
83
83
  # if there is an extra key, other than 'name' and 'value', insert that key as is
84
84
  add_elements(path, current.flat_map{ |h| h.except('name', 'value').to_a})
@@ -13,6 +13,10 @@ require 'shellwords'
13
13
 
14
14
  module Aspera
15
15
  # detect OS, architecture, and specific stuff
16
+ #
17
+ # @!method self.instance
18
+ # Returns the singleton instance of Environment
19
+ # @return [Environment] the singleton instance
16
20
  class Environment
17
21
  include Singleton
18
22
 
@@ -74,7 +78,9 @@ module Aspera
74
78
  argv
75
79
  end
76
80
 
77
- # like `Shellwords.shellescape`, but does not escape `=`
81
+ # Like `Shellwords.shellescape`, but does not escape `=`
82
+ # @param str [String] String to escape for shell usage
83
+ # @return [String] Shell-safe string
78
84
  def shell_escape_pretty(str)
79
85
  # Safe unquoted characters + '=' explicitly allowed
80
86
  return str if str.match?(%r{\A[A-Za-z0-9_.,:/@+=-]+\z})
@@ -111,7 +117,7 @@ module Aspera
111
117
  # @raise [RuntimeError] If `:exception` is `true` and the process fails in `:capture` mode.
112
118
  def secure_execute(*cmd, mode: :execute, **kwargs)
113
119
  cmd = cmd.map(&:to_s)
114
- Aspera.assert(cmd.size.positive?, type: ArgumentError){'executable must be present'}
120
+ Aspera.assert(cmd.size.positive?, 'executable must be present', type: ArgumentError)
115
121
  Aspera.assert_values(mode, PROCESS_MODES, type: ArgumentError){'mode'}
116
122
  Log.log.debug do
117
123
  parts = [mode.to_s, 'command:']
@@ -153,7 +159,7 @@ module Aspera
153
159
  # @param mode [Integer] the file mode (permissions)
154
160
  # @yieldreturn [String] The content to write to the file
155
161
  def write_file_restricted(path, force: false, mode: nil)
156
- Aspera.assert(block_given?, type: Aspera::InternalError)
162
+ Aspera.assert(block_given?, 'block required for write_file_restricted', type: Aspera::InternalError)
157
163
  if force || !File.exist?(path)
158
164
  # Windows may give error
159
165
  File.unlink(path) rescue nil
@@ -164,7 +170,10 @@ module Aspera
164
170
  return path
165
171
  end
166
172
 
167
- # restrict access to a file or folder to user only
173
+ # Restrict access to a file or folder to the current user only (chmod 600/700)
174
+ # @param path [String] Path to the file or directory
175
+ # @param mode [Integer, nil] Octal permission mode; if nil, inferred from path type
176
+ # @return [nil]
168
177
  def restrict_file_access(path, mode: nil)
169
178
  if mode.nil?
170
179
  # or FileUtils ?
@@ -177,11 +186,12 @@ module Aspera
177
186
  end
178
187
  end
179
188
  File.chmod(mode, path) unless mode.nil?
189
+ nil
180
190
  rescue => e
181
191
  Log.log.warn(e.message)
182
192
  end
183
193
 
184
- # @return true if we are in a terminal
194
+ # @return [Boolean] true if we are in a terminal
185
195
  def terminal?
186
196
  $stdout.tty?
187
197
  end
@@ -191,11 +201,25 @@ module Aspera
191
201
  I18N_VARS.each{ |var| ENV[var] = 'C'}
192
202
  end
193
203
 
194
- # @return true if we can display Unicode characters
204
+ # @return [Boolean] true if we can display Unicode characters
205
+ # Uses Encoding.locale_charmap for OS-independent detection.
206
+ # Falls back to locale env vars for systems where charmap is not available.
195
207
  # https://www.gnu.org/software/libc/manual/html_node/Locale-Categories.html
196
208
  # https://pubs.opengroup.org/onlinepubs/7908799/xbd/envvar.html
197
209
  def terminal_supports_unicode?
198
- terminal? && I18N_VARS.any?{ |var| ENV[var]&.include?('UTF-8')}
210
+ return false unless terminal?
211
+ locale_charmap_utf8? || I18N_VARS.any?{ |var| ENV[var]&.include?('UTF-8')}
212
+ end
213
+
214
+ private
215
+
216
+ # @return [Boolean] true if the locale charmap resolves to UTF-8
217
+ # Unix: nl_langinfo(CODESET) returns "UTF-8"
218
+ # Windows: GetACP returns "CP65001" which Encoding.find resolves as UTF-8
219
+ def locale_charmap_utf8?
220
+ Encoding.find(Encoding.locale_charmap) == Encoding::UTF_8
221
+ rescue ArgumentError
222
+ false
199
223
  end
200
224
  end
201
225
  attr_accessor :url_method, :file_illegal_characters
@@ -266,22 +290,24 @@ module Aspera
266
290
  return unless @os.eql?(OS_WINDOWS) && ENV.key?('USERPROFILE') && Dir.exist?(ENV.fetch('USERPROFILE', nil))
267
291
  ENV['HOME'] = ENV.fetch('USERPROFILE', nil)
268
292
  Log.log.debug{"Windows: set HOME to USERPROFILE: #{Dir.home}"}
293
+ nil
269
294
  end
270
295
 
271
296
  def graphical?
272
297
  @default_gui_mode == :graphical
273
298
  end
274
299
 
275
- # Open a URI in a graphical browser
276
- # Command must be non blocking
277
- # @param uri [String] the URI to open
300
+ # Open a URI in the system's default graphical browser (non-blocking)
301
+ # @param uri [String, URI] the URI to open
302
+ # @return [nil]
278
303
  def open_uri_graphical(uri)
279
304
  case @os
280
- when Environment::OS_MACOS then return self.class.secure_execute('open', uri.to_s)
281
- when Environment::OS_WINDOWS then return self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
282
- when Environment::OS_LINUX then return self.class.secure_execute('xdg-open', uri.to_s)
305
+ when Environment::OS_MACOS then self.class.secure_execute('open', uri.to_s)
306
+ when Environment::OS_WINDOWS then self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
307
+ when Environment::OS_LINUX then self.class.secure_execute('xdg-open', uri.to_s)
283
308
  else Aspera.error_unexpected_value(os){'no graphical open method'}
284
309
  end
310
+ nil
285
311
  end
286
312
 
287
313
  # open a file in an editor
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aspera
4
+ # Holds the executable name, environment variables and command line arguments for a spawned process.
5
+ ExecSpec = Struct.new(:exec, :env, :args, keyword_init: true) do
6
+ def initialize(exec: nil, env: {}, args: [], **) = super
7
+
8
+ # @return [ExecSpec] deep copy with independent :args and :env
9
+ def deep_clone
10
+ self.class.new(exec: exec, env: env.dup, args: args.dup)
11
+ end
12
+ end
13
+ end
@@ -68,7 +68,7 @@ module Aspera
68
68
  case request.path
69
69
  when '/aspera/faspex/send'
70
70
  begin
71
- Aspera.assert(!request.body.nil?){'payload missing'}
71
+ Aspera.assert(!request.body.nil?, 'payload missing')
72
72
  faspex_pkg_parameters = JSON.parse(request.body)
73
73
  Log.log.debug{"faspex pkg create parameters=#{faspex_pkg_parameters}"}
74
74
  # compare string, as class is not yet known here
@@ -84,7 +84,7 @@ module Aspera
84
84
  response.status = 200
85
85
  response.content_type = Mime::JSON
86
86
  response.body = JSON.generate(faspex_package_create_result)
87
- rescue => e
87
+ rescue StandardError => e
88
88
  response.status = 500
89
89
  response['Content-Type'] = Mime::JSON
90
90
  response.body = {error: e.message, stacktrace: e.backtrace}.to_json
@@ -16,7 +16,7 @@ module Aspera
16
16
  @parameters = parameters.symbolize_keys
17
17
  Log.dump(:post_proc_parameters, @parameters)
18
18
  not_allowed = @parameters.keys - ALLOWED_PARAMETERS
19
- raise "unsupported parameters: #{not_allowed.join(', ')}" unless not_allowed.empty?
19
+ Aspera.assert(not_allowed.empty?){"unsupported parameters: #{not_allowed.join(', ')}"}
20
20
  @parameters[:script_folder] ||= '.'
21
21
  @parameters[:fail_on_error] ||= false
22
22
  @parameters[:timeout_seconds] ||= 60
@@ -49,6 +49,7 @@ module Aspera
49
49
  Log.log.debug{"script=#{script_path}"}
50
50
  webhook_parameters = JSON.parse(request.body)
51
51
  Log.dump(:webhook_parameters, webhook_parameters)
52
+ process_status = nil
52
53
  if request.query.key?('lambda')
53
54
  # Code can throw exception, source code must return a lambda
54
55
  Environment.secure_eval(File.read(script_path), __FILE__, __LINE__).call(webhook_parameters)
@@ -62,11 +63,11 @@ module Aspera
62
63
  post_proc_pid = nil
63
64
  end
64
65
  process_status = $CHILD_STATUS
65
- raise "script #{script_path} failed with code #{process_status.exitstatus}" if !process_status.success? && @parameters[:fail_on_error]
66
+ Aspera.assert(process_status.success? || !@parameters[:fail_on_error]){"script #{script_path} failed with code #{process_status.exitstatus}"}
66
67
  end
67
68
  response.status = 200
68
69
  response.content_type = Mime::JSON
69
- response.body = JSON.generate({status: 'success', script: script_path, exit_code: process_status.exitstatus})
70
+ response.body = JSON.generate({status: 'success', script: script_path, exit_code: process_status&.exitstatus})
70
71
  Log.log.debug{'Script executed successfully'}
71
72
  rescue => e
72
73
  Log.log.error("Script failed: #{e.class}:#{e.message}")