aspera-cli 4.26.2 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  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 +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -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.2'
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,12 +75,12 @@ 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.empty?){"No known application found at #{app_url}"}
83
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
@@ -122,7 +123,7 @@ 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
@@ -133,7 +134,7 @@ module Aspera
133
134
  answer = options.prompt_user_input_in_list('product', apps.map{ |a| a[:product]})
134
135
  apps.find{ |a| a[:product].eql?(answer)}
135
136
  end
136
- wiz_preset_name = options.get_next_argument('preset name', default: '')
137
+ wiz_preset_name = preset_name
137
138
  Log.dump(:identification, identification)
138
139
  wiz_url = identification[:url]
139
140
  formatter.display_status("Using: #{identification[:name]} at #{wiz_url}".bold)
@@ -160,7 +161,7 @@ module Aspera
160
161
  # Init defaults if necessary
161
162
  option_override = options.get_option(:override, mandatory: true)
162
163
  option_default = options.get_option(:default, mandatory: true)
163
- 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)
164
165
  test_args = wizard_result[:test_args]
165
166
  test_args = "-P#{wiz_preset_name} #{test_args}" unless option_default
166
167
  # TODO: actually test the command
@@ -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
@@ -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,6 +3,7 @@
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
@@ -23,7 +24,7 @@ module Aspera
23
24
  # @return [String] decoded data
24
25
  def item(name)
25
26
  index = ELEMENTS.index(name)
26
- raise ParameterError, "Unknown data item #{name} (#{name.class})" unless index
27
+ Aspera.assert(index, type: ParameterError){"Unknown data item #{name} (#{name.class})"}
27
28
  raw_data = data(START_INDEX + index)
28
29
  case name
29
30
  when :dsa, :rsa
@@ -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})
@@ -78,7 +78,9 @@ module Aspera
78
78
  argv
79
79
  end
80
80
 
81
- # 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
82
84
  def shell_escape_pretty(str)
83
85
  # Safe unquoted characters + '=' explicitly allowed
84
86
  return str if str.match?(%r{\A[A-Za-z0-9_.,:/@+=-]+\z})
@@ -168,7 +170,10 @@ module Aspera
168
170
  return path
169
171
  end
170
172
 
171
- # 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]
172
177
  def restrict_file_access(path, mode: nil)
173
178
  if mode.nil?
174
179
  # or FileUtils ?
@@ -181,11 +186,12 @@ module Aspera
181
186
  end
182
187
  end
183
188
  File.chmod(mode, path) unless mode.nil?
189
+ nil
184
190
  rescue => e
185
191
  Log.log.warn(e.message)
186
192
  end
187
193
 
188
- # @return true if we are in a terminal
194
+ # @return [Boolean] true if we are in a terminal
189
195
  def terminal?
190
196
  $stdout.tty?
191
197
  end
@@ -195,7 +201,7 @@ module Aspera
195
201
  I18N_VARS.each{ |var| ENV[var] = 'C'}
196
202
  end
197
203
 
198
- # @return true if we can display Unicode characters
204
+ # @return [Boolean] true if we can display Unicode characters
199
205
  # Uses Encoding.locale_charmap for OS-independent detection.
200
206
  # Falls back to locale env vars for systems where charmap is not available.
201
207
  # https://www.gnu.org/software/libc/manual/html_node/Locale-Categories.html
@@ -207,7 +213,7 @@ module Aspera
207
213
 
208
214
  private
209
215
 
210
- # @return true if the locale charmap resolves to UTF-8
216
+ # @return [Boolean] true if the locale charmap resolves to UTF-8
211
217
  # Unix: nl_langinfo(CODESET) returns "UTF-8"
212
218
  # Windows: GetACP returns "CP65001" which Encoding.find resolves as UTF-8
213
219
  def locale_charmap_utf8?
@@ -291,16 +297,17 @@ module Aspera
291
297
  @default_gui_mode == :graphical
292
298
  end
293
299
 
294
- # Open a URI in a graphical browser
295
- # Command must be non blocking
296
- # @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]
297
303
  def open_uri_graphical(uri)
298
304
  case @os
299
- when Environment::OS_MACOS then return self.class.secure_execute('open', uri.to_s)
300
- when Environment::OS_WINDOWS then return self.class.secure_execute('start', 'explorer', %Q{"#{uri}"})
301
- 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)
302
308
  else Aspera.error_unexpected_value(os){'no graphical open method'}
303
309
  end
310
+ nil
304
311
  end
305
312
 
306
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
@@ -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}")
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest_call_error'
4
+
5
+ module Aspera
6
+ # Helper to execute GraphQL queries via a Rest object.
7
+ # Follows the GraphQL-over-HTTP convention: POST {query, variables} to endpoint,
8
+ # parse top-level `data` key, raise on top-level `errors` array.
9
+ module GraphQL
10
+ QUERIES_FOLDER = File.join(__dir__, 'api', 'queries')
11
+ private_constant :QUERIES_FOLDER
12
+
13
+ class << self
14
+ # Execute a GraphQL query loaded from a .graphql file in lib/aspera/api/queries/
15
+ # @param rest [Rest] API object pointing at the GraphQL endpoint
16
+ # @param name [String] Base filename without extension (e.g. 'my_query')
17
+ # @param variables [Hash] GraphQL variables
18
+ # @return [Hash] The `data` object from the response
19
+ # @raise [RestCallError] if the response contains GraphQL errors
20
+ def execute(rest, name, variables = {})
21
+ query = load_query(name)
22
+ response = rest.create(nil, {query: query, variables: variables})
23
+ errors = response['errors']
24
+ raise RestCallError, errors.map{ |e| e['message']}.join("\n") if errors.is_a?(Array) && !errors.empty?
25
+ response['data']
26
+ end
27
+
28
+ private
29
+
30
+ def load_query(name)
31
+ File.read(File.join(QUERIES_FOLDER, "#{name}.graphql"))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ::Hash
4
+ # Recursively merge another hash, with nested hashes merged deeply
5
+ # @param second [Hash] Hash to merge into self
6
+ # @return [Hash] New merged hash
4
7
  def deep_merge(second)
5
8
  merge(second){ |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge(v2) : v2}
6
9
  end
7
10
 
11
+ # Recursively merge another hash in-place, with nested hashes merged deeply
12
+ # @param second [Hash] Hash to merge into self
13
+ # @return [Hash] self
8
14
  def deep_merge!(second)
9
15
  merge!(second){ |_key, v1, v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge!(v2) : v2}
10
16
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore blankslate jsonrpc
4
+
5
+ require 'aspera/rest_error_analyzer'
6
+ require 'aspera/assert'
7
+ require 'aspera/json_rpc/version'
8
+ require 'blankslate'
9
+
10
+ Aspera::RestErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message], always: true)
11
+
12
+ module Aspera
13
+ module JsonRpc
14
+ # JSON-RPC 2.0 client over an Aspera::Rest HTTP endpoint.
15
+ # Methods are dispatched dynamically via method_missing.
16
+ # Example:
17
+ # client = JsonRpc::Client.new(Rest.new(base_url: 'http://127.0.0.1:33024'))
18
+ # client.get_info
19
+ # client.start_transfer(app_id: '...', transfer_spec: {...})
20
+ class Client < BlankSlate
21
+ reveal :instance_variable_get
22
+ reveal :inspect
23
+ reveal :to_s
24
+
25
+ # @param api [Rest] Aspera REST object pointing at the JSON-RPC endpoint
26
+ # @param namespace [String, nil] optional method prefix, e.g. "myns."
27
+ def initialize(api, namespace = nil)
28
+ super()
29
+ @api = api
30
+ @namespace = namespace
31
+ @request_id = 0
32
+ end
33
+
34
+ def respond_to_missing?(_sym, _include_private = false)
35
+ true
36
+ end
37
+
38
+ # Dispatch any Ruby method call as a JSON-RPC request
39
+ def method_missing(method, *args, &block)
40
+ args = args.first if args.size == 1 && args.first.is_a?(Hash)
41
+ data = @api.create('', {
42
+ jsonrpc: VERSION,
43
+ method: "#{@namespace}#{method}",
44
+ params: args,
45
+ id: @request_id += 1
46
+ })
47
+ Aspera.assert_type(data, Hash){'response'}
48
+ Aspera.assert(data['jsonrpc'] == VERSION, 'bad version in response')
49
+ Aspera.assert(data.key?('id'), 'missing id in response')
50
+ Aspera.assert(!(data.key?('error') && data.key?('result')), 'both error and response')
51
+ Aspera.assert(
52
+ !data.key?('error') ||
53
+ data['error'].is_a?(Hash) &&
54
+ data['error']['code'].is_a?(Integer) &&
55
+ data['error']['message'].is_a?(String),
56
+ 'bad error response'
57
+ )
58
+ return data['result']
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aspera
4
+ module JsonRpc
5
+ VERSION = '2.0'
6
+ end
7
+ end
@@ -64,7 +64,7 @@ module Aspera
64
64
  def set(options)
65
65
  validate_set(options)
66
66
  label = options.delete(:label)
67
- raise "secret #{label} already exist, delete first" if @all_secrets.key?(label)
67
+ Aspera.assert(!@all_secrets.key?(label)){"secret #{label} already exist, delete first"}
68
68
  @all_secrets[label] = options.symbolize_keys
69
69
  save
70
70
  end
@@ -24,19 +24,19 @@ module Aspera
24
24
  info[:password] = password
25
25
  # this module requires compilation, so it is optional
26
26
  require 'aspera/keychain/encrypted_hash'
27
- @vault = Keychain::EncryptedHash.new(**info)
27
+ Keychain::EncryptedHash.new(**info)
28
28
  when 'system'
29
29
  case Environment.instance.os
30
30
  when Environment::OS_MACOS
31
31
  info[:name] ||= name
32
- @vault = Keychain::MacosSystem.new(**info)
32
+ Keychain::MacosSystem.new(**info)
33
33
  else
34
34
  raise Error, 'not implemented for this OS'
35
35
  end
36
36
  when 'vault'
37
37
  require 'aspera/keychain/hashicorp_vault'
38
38
  info[:token] ||= password
39
- @vault = Keychain::HashicorpVault.new(**info)
39
+ Keychain::HashicorpVault.new(**info)
40
40
  else Aspera.error_unexpected_value(vault_type)
41
41
  end
42
42
  end
@@ -44,7 +44,7 @@ module Aspera
44
44
  uri = URI.parse(url)
45
45
  Aspera.assert(uri.scheme.eql?('https'), 'only https')
46
46
  options[:protocol] = 'htps' # cspell: disable-line
47
- raise Error, 'host required in URL' if uri.host.nil?
47
+ Aspera.assert(!uri.host.nil?, type: Error){'host required in URL'}
48
48
  options[:server] = uri.host
49
49
  options[:path] = uri.path unless ['', '/'].include?(uri.path)
50
50
  options[:port] = uri.port unless uri.port.eql?(443) && !url.include?(':443/')
@@ -128,7 +128,7 @@ module Aspera
128
128
  super()
129
129
  @keychain_name = name.nil? ? 'default keychain' : name
130
130
  @keychain = name.nil? ? MacosSecurity::Keychain.default : MacosSecurity::Keychain.by_name(name)
131
- raise "no such keychain #{name}" if @keychain.nil?
131
+ Aspera.assert(!@keychain.nil?){"no such keychain #{name}"}
132
132
  end
133
133
 
134
134
  def info
@@ -155,7 +155,7 @@ module Aspera
155
155
  unsupported = options.keys - %i[label]
156
156
  Aspera.assert(unsupported.empty?){"unsupported options: #{unsupported}"}
157
157
  info = @keychain.password(:find, :generic, label: options[:label])
158
- raise Error, 'not found' if info.nil?
158
+ Aspera.assert(!info.nil?, type: Error){'not found'}
159
159
  result = options.clone
160
160
  result[:secret] = info['password']
161
161
  result[:description] = info['icmt'] # cspell: disable-line