aspera-cli 4.27.5 → 4.28.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 (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -4,24 +4,29 @@ require 'aspera/cli/command_spec'
4
4
  require 'aspera/cli/context'
5
5
  require 'aspera/cli/parser'
6
6
  require 'aspera/cli/formatter'
7
+ require 'aspera/cli/table_fitter'
7
8
  require 'aspera/cli/plugins/factory'
8
9
  require 'aspera/cli/bootstrapper'
10
+ require 'aspera/rest/aspera_errors' # registers error handlers of Aspera REST APIs
9
11
  require 'aspera/cli/plugins/config'
10
12
  require 'aspera/cli/mailer'
11
13
  require 'aspera/cli/secret_finder'
12
14
  require 'aspera/cli/extended_value'
15
+ require 'aspera/cli/security'
13
16
  require 'aspera/cli/transfer_agent'
14
17
  require 'aspera/cli/version'
15
18
  require 'aspera/cli/info'
16
19
  require 'aspera/cli/hints'
17
20
  require 'aspera/cli/result'
18
21
  require 'aspera/transfer/result'
22
+ require 'aspera/duration'
19
23
  require 'aspera/secret_hider'
20
24
  require 'aspera/log'
21
25
  require 'aspera/assert'
22
26
  require 'aspera/schema/documentation'
23
27
  require 'aspera/schema/registry'
24
28
  require 'net/ssh/errors'
29
+ require 'net/protocol'
25
30
  require 'openssl'
26
31
 
27
32
  module Aspera
@@ -49,6 +54,17 @@ module Aspera
49
54
  end
50
55
  end
51
56
 
57
+ # Result of a command that creates an object and transfers its files, e.g. `packages send`.
58
+ # @param object [Hash] Created object
59
+ # @param transfer_result [Transfer::Result] Typed result from TransferAgent#start
60
+ # @raise [StandardError] if the transfer failed
61
+ # @return [Result::SingleObject] The object, with field `transfer` (`job_id`) if the transfer is asynchronous
62
+ def result_object_transfer(object, transfer_result)
63
+ status = result_transfer(transfer_result)
64
+ object = object.merge('transfer' => status.data) if status.is_a?(Result::SingleObject)
65
+ Result::SingleObject.new(object)
66
+ end
67
+
52
68
  # Used when one command executes several transfer jobs (each job being possibly multi session)
53
69
  # @param status_table [Array] [{STATUS_FIELD=>Transfer::Result,...},...]
54
70
  # @return [Result] a status object suitable as command result
@@ -94,7 +110,8 @@ module Aspera
94
110
  Plugins::Factory.instance.add_plugins_from_lookup_folders
95
111
  # Help requested without command? Show global options + plugin list
96
112
  return result_usage if @option_help && @context.options.command_or_arg_empty?
97
- @context.config.periodic_check_newer_gem_version
113
+ # Shell completion is executed on each `Tab`: no network access
114
+ @context.config.periodic_check_newer_gem_version unless @context.options.next_arguments?(COMPLETION_COMMAND)
98
115
  command_sym =
99
116
  if @option_show_config && @context.options.command_or_arg_empty?
100
117
  COMMAND_CONFIG
@@ -158,18 +175,18 @@ module Aspera
158
175
  begin
159
176
  result = run_with_result
160
177
  @context.formatter.display_results(result) if result
161
- rescue Net::SSH::AuthenticationFailed => e; exception_info = {e: e, t: 'SSH', security: true}
162
- rescue OpenSSL::SSL::SSLError => e; exception_info = {e: e, t: 'SSL'}
163
- rescue Cli::BadArgument => e; exception_info = {e: e, t: 'Argument', usage: true}
164
- rescue Cli::MissingArgument => e; exception_info = {e: e, t: 'Missing'}
165
- rescue Cli::BadIdentifier => e; exception_info = {e: e, t: 'Identifier'}
166
- rescue Cli::SchemaRequest => e; exception_info = {e: e, t: 'Schema'}
167
- rescue Cli::Error => e; exception_info = {e: e, t: 'Tool', usage: true}
168
- rescue Transfer::Error => e; exception_info = {e: e, t: 'Transfer'}
169
- rescue Rest::CallError => e; exception_info = {e: e, t: 'Rest'}
170
- rescue SocketError => e; exception_info = {e: e, t: 'Network'}
171
- rescue StandardError => e; exception_info = {e: e, t: "Other(#{e.class.name})", debug: true}
172
- rescue Interrupt => e; exception_info = {e: e, t: 'Interruption', debug: true}
178
+ rescue Net::SSH::AuthenticationFailed => e; exception_info = {e: e, t: 'SSH', security: true, exit: ExitCode::AUTH}
179
+ rescue OpenSSL::SSL::SSLError => e; exception_info = {e: e, t: 'SSL', exit: ExitCode::NETWORK}
180
+ rescue Cli::BadArgument => e; exception_info = {e: e, t: 'Argument', usage: true, exit: ExitCode::USAGE}
181
+ rescue Cli::MissingArgument => e; exception_info = {e: e, t: 'Missing', exit: ExitCode::USAGE}
182
+ rescue Cli::BadIdentifier, EntityNotFound => e; exception_info = {e: e, t: 'Identifier', exit: ExitCode::NOT_FOUND}
183
+ rescue Cli::SchemaRequest => e; exception_info = {e: e, t: 'Schema', exit: ExitCode::SUCCESS}
184
+ rescue Cli::Error => e; exception_info = {e: e, t: 'Tool', usage: true, exit: ExitCode::ERROR}
185
+ rescue Transfer::Error => e; exception_info = {e: e, t: 'Transfer', exit: ExitCode::TRANSFER}
186
+ rescue Rest::CallError => e; exception_info = {e: e, t: 'Rest', exit: ExitCode.for_call_error(e)}
187
+ rescue *NETWORK_ERRORS => e; exception_info = {e: e, t: 'Network', exit: ExitCode::NETWORK}
188
+ rescue StandardError => e; exception_info = {e: e, t: "Other(#{e.class.name})", debug: true, exit: ExitCode::ERROR}
189
+ rescue Interrupt => e; exception_info = {e: e, t: 'Interruption', debug: true, exit: ExitCode::INTERRUPTED}
173
190
  end
174
191
  # 1- processing of error condition
175
192
  unless exception_info.nil?
@@ -180,35 +197,28 @@ module Aspera
180
197
  Hints.hint_for(exception_info[:e], @context.formatter)
181
198
  if exception_info[:e].is_a?(Cli::SchemaRequest)
182
199
  Log.log.info { "#{exception_info[:t]}: #{exception_info[:e].message}" }
183
- schema_path = exception_info[:e].path
184
- if schema_path.nil?
185
- Log.log.warn { 'Sorry, no schema provided yet. Please refer to the manual or API.' }
186
- else
187
- reader = Schema::Registry.instance.reader(schema_path)
188
- rows = reader.to_rows.map do |row|
189
- if row.key?('enum')
190
- row = row.dup
191
- row['type'] = 'enum'
192
- row['description'] = "#{row['description']}\nAllowed: #{row['enum'].join(', ')}"
193
- end
194
- row
195
- end
196
- @context.formatter.display_results(Result::ObjectList.new(rows, fields: %w[name type required description]))
197
- end
200
+ display_schema(exception_info[:e].path)
198
201
  end
199
202
  end
200
203
  # 2- processing of unprocessed arguments (skip when help was displayed: sub-commands are not consumed)
201
204
  unless @option_help
202
205
  @context.options&.final_errors&.each do |msg|
203
206
  Log.log.error { "Argument: #{msg}" }
204
- exception_info = {e: Exception.new(msg), t: 'UnusedArg'} if exception_info.nil?
207
+ exception_info = {e: Exception.new(msg), t: 'UnusedArg', exit: ExitCode::USAGE} if exception_info.nil?
205
208
  end
206
209
  end
207
210
  # 3- exit on error
208
211
  unless exception_info.nil?
209
- raise exception_info[:e] if Log.log.debug?
210
- @context.formatter.display_message(:error, 'Use --log-level=debug to get more details.') if exception_info[:debug]
211
- Process.exit(1)
212
+ if Log.log.debug?
213
+ # Same display as an uncaught exception, but keeps the exit code
214
+ $stderr.puts(exception_info[:e].full_message) # rubocop:disable Style/StderrPuts
215
+ elsif exception_info[:debug]
216
+ @context.formatter.display_message(:error, 'Use --log-level=debug to get more details.')
217
+ end
218
+ exit_code = exception_info[:exit]
219
+ # Nagios plugins report the failure of the check itself as UNKNOWN
220
+ exit_code = Nagios::UNKNOWN if !exit_code.eql?(ExitCode::SUCCESS) && @context.formatter&.format_type.eql?(:nagios)
221
+ Process.exit(exit_code)
212
222
  end
213
223
  return
214
224
  end
@@ -228,6 +238,28 @@ module Aspera
228
238
  Result::Text.new(usage_text(plugin: plugin))
229
239
  end
230
240
 
241
+ # `on_set` callback of the `ui` option (dot-notation sub-properties): `mode`, `command`
242
+ # A plain value is the `mode`. Values are validated against schema `UiOptions`.
243
+ # @param value [Hash] sub-properties to set
244
+ def option_ui=(value)
245
+ Aspera.assert_type(value, Hash)
246
+ value.each do |k, v|
247
+ case k.to_sym
248
+ when :mode
249
+ Environment.instance.url_method =
250
+ case v.to_s
251
+ when 'auto' then Environment.instance.default_gui_mode
252
+ when 'terminal', 'text' then :text
253
+ when 'browser', 'graphical' then :graphical
254
+ else Aspera.error_unexpected_value(v, type: BadArgument) { 'ui.mode' }
255
+ end
256
+ when :command
257
+ Environment.instance.browser_command = v.nil? || v.empty? ? nil : v.map(&:to_s)
258
+ else Aspera.error_unexpected_value(k, type: BadArgument) { 'ui sub-option (mode, command)' }
259
+ end
260
+ end
261
+ end
262
+
231
263
  # `on_set` callback of the `log` option (dot-notation sub-properties): `level`, `type`, `format`, `secrets`
232
264
  # @param value [Hash] sub-properties to set
233
265
  def option_log=(value)
@@ -243,6 +275,29 @@ module Aspera
243
275
  end
244
276
  end
245
277
 
278
+ # `on_set` callback of the `temp` option (dot-notation sub-properties): `folder`, `clean`, `expiry`
279
+ # A plain value is the `folder`. Values are validated against schema `TempOptions`.
280
+ # @param value [Hash] sub-properties to set
281
+ def option_temp=(value)
282
+ Aspera.assert_type(value, Hash)
283
+ value.each do |k, v|
284
+ case k.to_sym
285
+ when :folder then TempFileManager.instance.global_temp = v
286
+ when :clean then TempFileManager.instance.cleanup_on_exit = Parser.get_from_list(v.to_s, 'temp clean', BoolValue::ALL)
287
+ when :expiry
288
+ TempFileManager.instance.expiry_sec =
289
+ case v
290
+ when nil, false then nil
291
+ when Numeric then v
292
+ else Duration.to_unit(v, 's')
293
+ end
294
+ else Aspera.error_unexpected_value(k, type: BadArgument) { 'temp sub-option (folder, clean, expiry)' }
295
+ end
296
+ end
297
+ rescue ParameterError => e
298
+ raise BadArgument, "Option temp: #{e.message}"
299
+ end
300
+
246
301
  private
247
302
 
248
303
  # Build the usage/help text.
@@ -327,16 +382,36 @@ module Aspera
327
382
  # @param arg [ArgumentSpec]
328
383
  # @return [Array<String>]
329
384
  def schema_help_lines(arg)
330
- builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(arg.schema)).build
331
- rows = builder.rows.reject { |row| row['type'].eql?('&nbsp;') }.map do |row|
332
- builder.columns.map { |column| row[column].to_s }
385
+ builder = schema_documentation(arg.schema)
386
+ rows = builder.rows.map do |row|
387
+ builder.columns.map { |column| Array(row[column]).join("\n") }
333
388
  end
334
389
  style = {}
335
390
  style[:border] = :unicode_round if Environment.terminal_supports_unicode?
336
- table = Terminal::Table.new(headings: builder.columns, rows: rows, style: style).to_s
391
+ table = TableFitter.table(headings: builder.columns, rows: rows, style: style, width: Environment.terminal_columns&.-(4)).to_s
337
392
  ["\n SCHEMA: #{arg.name}", table.lines.map { |line| " #{line.chomp}" }.join("\n")]
338
393
  end
339
394
 
395
+ # Display the documentation of a schema: value `help` given to an option or argument
396
+ # @param schema_path [String, nil] Path of the schema in the registry, `nil` if none
397
+ # @return [nil]
398
+ def display_schema(schema_path)
399
+ if schema_path.nil?
400
+ Log.log.warn { 'Sorry, no schema provided yet. Please refer to the manual or API.' }
401
+ return
402
+ end
403
+ builder = schema_documentation(schema_path)
404
+ @context.formatter.display_results(Result::ObjectList.new(builder.rows, fields: builder.columns))
405
+ nil
406
+ end
407
+
408
+ # @param schema_path [String] Path of the schema in the registry
409
+ # @return [Schema::Documentation] Documentation of the schema, for the terminal
410
+ def schema_documentation(schema_path)
411
+ options = schema_path.eql?(Schema::Registry::TRANSFER_SPEC) ? Transfer::Spec.doc_options : {}
412
+ Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(schema_path), **options).build
413
+ end
414
+
340
415
  # Initialize agents and options
341
416
  # This can throw exception if there is a problem with the environment, needs to be caught by execute method
342
417
  # @raise [StandardError] if there is a problem with the environment
@@ -413,6 +488,9 @@ module Aspera
413
488
 
414
489
  ARGS
415
490
  #{t}Some commands require mandatory arguments, e.g. a path.
491
+
492
+ EXIT STATUS
493
+ #{ExitCode::DESCRIPTIONS.map { |code, description| "#{t}#{code.to_s.ljust(5)}#{description}" }.join("\n")}
416
494
  END_OF_BANNER
417
495
  end
418
496
 
@@ -427,9 +505,9 @@ module Aspera
427
505
  @context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Type::NONE) { @option_show_config = true }
428
506
  @context.options.declare(:version, description: 'Display version', allowed: Type::NONE, short: 'v') { @context.formatter.display_message(:data, Cli::VERSION); Process.exit(0) } # rubocop:disable Style/Semicolon
429
507
  @context.options.declare(
430
- :ui, description: 'Method to start browser',
431
- allowed: USER_INTERFACES, default: Environment.instance.url_method,
432
- on_set: Environment.instance.method(:url_method=)
508
+ :ui, description: 'User interface (dot-notation: mode, command)',
509
+ allowed: [Hash, String], shorthand: 'mode', schema: Schema::Registry::UI_OPTIONS,
510
+ on_set: method(:option_ui=)
433
511
  )
434
512
  @context.options.declare(
435
513
  :invalid_characters, description: 'Replacement character and invalid filename characters',
@@ -440,11 +518,17 @@ module Aspera
440
518
  @context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String], on_set: Log.instance.method(:formatter=))
441
519
  @context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES, on_set: Log.instance.method(:logger_type=))
442
520
  @context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)', on_set: method(:option_log=), schema: Schema::Registry::LOG_OPTIONS)
521
+ @context.options.declare(:security, description: 'Security restrictions (dot-notation: level, allowed_paths)', on_set: Security.instance.method(:options=), schema: Schema::Registry::SECURITY_OPTIONS)
443
522
  @context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Type::INTEGER)
444
523
  @context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Type::BOOLEAN, default: false)
445
524
  @context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Type::BOOLEAN, default: SecretHider.instance.log_secrets, on_set: SecretHider.instance.method(:log_secrets=))
446
- @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN, default: TempFileManager.instance.cleanup_on_exit, on_set: TempFileManager.instance.method(:cleanup_on_exit=))
447
- @context.options.declare(:temp_folder, description: 'Temporary folder', default: TempFileManager.instance.global_temp, on_set: TempFileManager.instance.method(:global_temp=))
525
+ @context.options.declare(
526
+ :temp, description: 'Temporary files (dot-notation: folder, clean, expiry)',
527
+ allowed: [Hash, String], shorthand: 'folder', schema: Schema::Registry::TEMP_OPTIONS,
528
+ on_set: method(:option_temp=)
529
+ )
530
+ @context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN, on_set: TempFileManager.instance.method(:cleanup_on_exit=), deprecation: {last: '4.27.5', message: 'use --temp.clean'})
531
+ @context.options.declare(:temp_folder, description: 'Temporary folder', on_set: TempFileManager.instance.method(:global_temp=), deprecation: {last: '4.27.5', message: 'use --temp.folder'})
448
532
  @context.options.declare(:pid_file, description: 'Write process identifier to file, delete on exit')
449
533
  @context.options.declare(
450
534
  :parser, description: 'Default parser for structured parameters and options',
@@ -471,12 +555,18 @@ module Aspera
471
555
  end
472
556
  COMMAND_CONFIG = :config
473
557
  COMMAND_HELP = :help
558
+ # Shell completion commands (scripts and words)
559
+ COMPLETION_COMMAND = %w[config completion].freeze
474
560
  # Types that go to result of type = text
475
561
  SCALAR_TYPES = [String, Integer, Symbol].freeze
476
- USER_INTERFACES = %i[text graphical].freeze
477
562
  EXTEND_ARGS = :''
563
+ # Errors of name resolution, connection and timeout
564
+ NETWORK_ERRORS = [
565
+ SocketError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
566
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
567
+ ].freeze
478
568
 
479
- private_constant :COMMAND_CONFIG, :COMMAND_HELP, :SCALAR_TYPES, :USER_INTERFACES, :EXTEND_ARGS
569
+ private_constant :COMMAND_CONFIG, :COMMAND_HELP, :COMPLETION_COMMAND, :SCALAR_TYPES, :EXTEND_ARGS, :NETWORK_ERRORS
480
570
  end
481
571
  end
482
572
  end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'aspera/assert'
5
+ require 'aspera/cli/error'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Security level: restricts the features that access local resources (files, environment, network) or execute code.
10
+ # On the command line, the default level is `low`: the user can do anything that the account is allowed to do.
11
+ # It can be raised, for example in MCP server mode, where commands come from a client.
12
+ #
13
+ # @!method self.instance
14
+ # Returns the singleton instance of Security
15
+ # @return [Security] the singleton instance
16
+ class Security
17
+ include Singleton
18
+
19
+ # Security levels, from least to most restrictive
20
+ LEVELS = %i[low medium high].freeze
21
+ # Extended-value handlers restricted by security level, with the levels that allow them
22
+ EXTENDED_VALUE_LEVELS = {
23
+ ruby: %i[low],
24
+ uri: %i[low],
25
+ env: %i[low],
26
+ file: %i[low medium]
27
+ }.freeze
28
+ # Levels that allow processing a secret (e.g. by an extended value): the result could reveal it, as only declared values are hidden
29
+ SECRET_PROCESSING_LEVELS = %i[low].freeze
30
+ private_constant :EXTENDED_VALUE_LEVELS, :SECRET_PROCESSING_LEVELS
31
+
32
+ def initialize
33
+ reset
34
+ end
35
+
36
+ # Back to default: level `low`, no allowed path, not locked
37
+ # @return [nil]
38
+ def reset
39
+ @level = nil
40
+ @default_level = LEVELS.first
41
+ @allowed_paths = []
42
+ @locked = nil
43
+ end
44
+
45
+ # @return [Symbol] Level set by option `security`, else default level
46
+ def level = @level || @default_level
47
+
48
+ # Set by option `security`.
49
+ # When locked, the value can only be set again to the current one.
50
+ # @param value [Hash] `level` and `allowed_paths`, see schema `SecurityOptions`
51
+ # @return [nil]
52
+ def options=(value)
53
+ Aspera.assert_type(value, Hash, type: BadArgument) { 'security' }
54
+ # When locked, each command starts with the empty value of a Hash option: not a change
55
+ return if @locked && value.empty?
56
+ value = value.transform_keys(&:to_sym)
57
+ new_level = value[:level]&.to_sym
58
+ Aspera.assert_values(new_level, LEVELS, type: BadArgument) { 'security.level' } unless new_level.nil?
59
+ new_paths = Array(value[:allowed_paths]).map do |path|
60
+ path = File.expand_path(path)
61
+ Aspera.assert(File.exist?(path), type: BadArgument) { "security.allowed_paths: no such file or folder: #{path}" }
62
+ File.realpath(path)
63
+ end
64
+ if @locked
65
+ Aspera.assert((new_level || @default_level).eql?(level) && new_paths.eql?(@allowed_paths), type: BadArgument) do
66
+ "security cannot be changed #{@locked}"
67
+ end
68
+ return
69
+ end
70
+ @level = new_level
71
+ @allowed_paths = new_paths
72
+ nil
73
+ end
74
+
75
+ # Prevent further changes, e.g. by commands of an MCP client
76
+ # @param reason [String] For error messages, e.g. `in MCP server mode`
77
+ # @param default [Symbol, nil] Level if not set by option
78
+ # @return [nil]
79
+ def lock(reason, default: nil)
80
+ Aspera.assert_values(default, LEVELS) unless default.nil?
81
+ @default_level = default unless default.nil?
82
+ @locked = reason
83
+ nil
84
+ end
85
+
86
+ # @param handler [Symbol] Extended-value handler
87
+ # @raise [BadArgument] If the handler is refused at current level, with the level that allows it and where to set it
88
+ def check_extended_value(handler)
89
+ allowed = EXTENDED_VALUE_LEVELS[handler]
90
+ return if allowed.nil? || allowed.include?(level)
91
+ # the least permissive level that allows it
92
+ needed = handler.eql?(:file) ? "level #{allowed.last} with the file in security.allowed_paths" : "level #{allowed.last}"
93
+ raise BadArgument, "@#{handler}: extended value is disabled by security level #{level}: use #{needed} (#{where_to_set}), or give the value itself"
94
+ end
95
+
96
+ # A secret (e.g. read by `@vault:`, `@secret:`, `@preset:`) is hidden in results only as declared: the result of processing it
97
+ # could reveal it (e.g. split by `@list:`, in an error message of `@json:`, short value in a text by `@extend:`, converted as name of parameter).
98
+ # @param what [String] What processes the value, e.g. `@list`
99
+ # @param hint [String] What to do instead
100
+ # @yieldreturn [Boolean] `true` if the value contains a secret, evaluated only if the level refuses it
101
+ # @raise [BadArgument] If the value contains a secret and processing it is refused at current level
102
+ def check_secret_processing(what, hint: 'give the secret as the whole value')
103
+ return if SECRET_PROCESSING_LEVELS.include?(level) || !yield
104
+ raise BadArgument, "#{what}: cannot process a secret at security level #{level}, as the result could reveal it: #{hint}, or use level low (#{where_to_set})"
105
+ end
106
+
107
+ # At level `medium`, the real path is checked, so that neither `..` nor a symbolic link leads outside of allowed paths.
108
+ # A missing file gets the same error as a file outside allowed paths: no information on other files.
109
+ # @param path [String] Path of a file to read
110
+ # @return [String] Path to read
111
+ # @raise [BadArgument] If reading the file is refused at current level
112
+ def readable_path(path)
113
+ path = File.expand_path(path)
114
+ case level
115
+ when :low then path
116
+ when :medium
117
+ real_path = File.realpath(path) rescue nil
118
+ Aspera.assert(!real_path.nil? && @allowed_paths.any? { |allowed| real_path.eql?(allowed) || real_path.start_with?(File.join(allowed, '')) }, type: BadArgument) do
119
+ "no such file in security.allowed_paths: #{path}"
120
+ end
121
+ real_path
122
+ else raise BadArgument, "reading files is disabled by security level #{level}"
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # @return [String] Where to set the level, for error messages
129
+ def where_to_set
130
+ @locked.nil? ? 'option security' : "option security when starting, as it cannot be changed #{@locked}"
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'terminal-table'
4
+ require 'unicode/display_width'
5
+
6
+ module Aspera
7
+ module Cli
8
+ # Build a terminal table that fits in a given width, by wrapping cells.
9
+ # Columns with text (containing spaces, e.g. `description`) are wrapped first, the widest first.
10
+ # If not enough, other columns (names, identifiers) are wrapped too.
11
+ # If the table cannot fit, columns are narrowed to their minimum width.
12
+ module TableFitter
13
+ # Minimum width of a wrapped column with text
14
+ MIN_TEXT_WIDTH = 20
15
+ # Minimum width of other wrapped columns
16
+ MIN_WIDTH = 10
17
+ # ANSI style (zero width)
18
+ STYLE = /\e\[[0-9;]*m/
19
+ # Styles that reset all styles
20
+ RESETS = ["\e[0m", "\e[m"].freeze
21
+ # ANSI style or character
22
+ TOKEN = /#{STYLE}|\X/
23
+ # Long words are preferably broken after one of these characters
24
+ BREAK_AFTER = %r{[-_./,=]}
25
+ private_constant :MIN_TEXT_WIDTH, :MIN_WIDTH, :STYLE, :RESETS, :TOKEN, :BREAK_AFTER
26
+
27
+ class << self
28
+ # @param headings [Array] Column names
29
+ # @param rows [Array<Array<String>>] Rows of cells
30
+ # @param style [Hash] `Terminal::Table` style
31
+ # @param width [Integer, nil] Maximum width of the table, or `nil` for natural width (not a `style` parameter)
32
+ # @return [Terminal::Table]
33
+ def table(headings:, rows:, style:, width:)
34
+ headings, rows = fit(headings, rows, width - overhead(headings.length, style)) unless width.nil?
35
+ Terminal::Table.new(headings: headings, rows: rows, style: style)
36
+ end
37
+
38
+ private
39
+
40
+ # @return [Integer] Width taken by borders and padding for a table with that number of columns
41
+ def overhead(column_count, style)
42
+ Terminal::Table.new(headings: [''] * column_count, style: style).to_s.lines.map { |line| display_width(line.chomp) }.max
43
+ end
44
+
45
+ # @param available [Integer] Width available for the content of all columns
46
+ # @return [Array] Headings and rows, wrapped to fit in available width, or as narrow as possible
47
+ def fit(headings, rows, available)
48
+ lines = [headings, *rows].map { |row| row.map(&:to_s) }
49
+ columns = lines.transpose
50
+ natural = columns.map { |cells| cells.flat_map { |cell| cell.split("\n") }.map { |line| display_width(line) }.max || 0 }
51
+ return [headings, rows] if natural.sum <= available
52
+ text = columns.map { |cells| cells.any? { |cell| cell.match?(/[ \t]/) } }
53
+ # Minimum width of a column, not narrower than its heading
54
+ floor = ->(i, min) { min.clamp(display_width(columns[i].first), natural[i]) }
55
+ # First wrap only columns with text, if not enough wrap all columns
56
+ text_only = natural.each_index.map { |i| text[i] ? floor.call(i, MIN_TEXT_WIDTH) : natural[i] }
57
+ all = natural.each_index.map { |i| floor.call(i, text[i] ? MIN_TEXT_WIDTH : MIN_WIDTH) }
58
+ minimum = text_only.sum <= available ? text_only : all
59
+ # Columns wider than `limit` are narrowed to it, but not below their minimum (`limit` is `-1` if the table cannot fit)
60
+ widths_for = ->(limit) { natural.zip(minimum).map { |nat, min| limit.clamp(min, nat) } }
61
+ limit = (0..natural.max).bsearch { |l| widths_for.call(l).sum > available } - 1
62
+ widths = widths_for.call(limit)
63
+ wrapped = lines.map { |row| row.each_with_index.map { |cell, i| wrap(cell, widths[i]) } }
64
+ [wrapped.first, wrapped.drop(1)]
65
+ end
66
+
67
+ # @return [String] Text with lines longer than width wrapped on spaces, and long words broken
68
+ def wrap(text, width)
69
+ text.split("\n").map do |line|
70
+ next line if display_width(line) <= width
71
+ wrapped = line.split.flat_map { |word| break_word(word, width) }.each_with_object([]) do |word, result|
72
+ if !result.empty? && display_width(result.last) + 1 + display_width(word) <= width
73
+ result[-1] = "#{result.last} #{word}"
74
+ else
75
+ result.push(word)
76
+ end
77
+ end
78
+ restyle(wrapped).join("\n")
79
+ end.join("\n")
80
+ end
81
+
82
+ # @return [Array<String>] Word split in pieces not wider than width, preferably after a punctuation
83
+ def break_word(word, width)
84
+ pieces = []
85
+ while display_width(word) > width
86
+ piece = +''
87
+ word.scan(TOKEN) do |token|
88
+ break if display_width(piece + token) > width && display_width(piece).positive?
89
+ piece << token
90
+ end
91
+ cut = piece.rindex(BREAK_AFTER)
92
+ piece = piece[0..cut] if cut && display_width(piece[0..cut]) > width / 2
93
+ pieces.push(piece)
94
+ word = word[piece.length..]
95
+ end
96
+ pieces.push(word)
97
+ end
98
+
99
+ # @return [Array<String>] Lines with ANSI styles closed at end of line, and open again on next line
100
+ def restyle(lines)
101
+ active = []
102
+ lines.map do |line|
103
+ result = active.join + line
104
+ line.scan(STYLE) { |style| RESETS.include?(style) ? active.clear : active.push(style) }
105
+ active.empty? ? result : "#{result}#{RESETS.first}"
106
+ end
107
+ end
108
+
109
+ # @return [Integer] Number of terminal columns used to display the string, ignoring ANSI escape sequences
110
+ def display_width(string)
111
+ Unicode::DisplayWidth.of(Terminal::Table::Util.ansi_escape(string))
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -20,52 +20,41 @@ module Aspera
20
20
  'HINT:'.bg(:green).white.blink
21
21
  end
22
22
 
23
- # Format boolean with colored symbol (+/- or Y/ )
23
+ # Format boolean with colored check mark, or Y and space without Unicode
24
24
  def tick(yes)
25
- result =
26
- if Environment.terminal_supports_unicode?
27
- yes ? "\u2713" : "\u2717"
28
- else
29
- yes ? 'Y' : ' '
30
- end
25
+ result = Environment.char(yes ? '✓' : '✗')
31
26
  return result.green if yes
32
- return result.red
27
+ result.red
33
28
  end
34
29
 
35
30
  # Format special values with colors (dim for empty, reverse for others)
36
31
  def special_format(what)
37
32
  result = "<#{what}>"
38
- return %w[null empty].any? { |s| what.include?(s) } ? result.faint : result.inverse
39
- end
40
-
41
- # Prepare table row for terminal display (word wrap arrays)
42
- def check_row(row)
43
- row.each_key do |k|
44
- row[k] = row[k].map { |i| WordWrap.ww(i.to_s, 120).chomp }.join("\n") if row[k].is_a?(Array)
45
- end
33
+ %w[null empty].any? { |s| what.include?(s) } ? result.faint : result.inverse
46
34
  end
47
35
 
48
- # Convert Markdown to terminal format (**bold** -> blue, `code` -> bold)
49
- # @param match [MatchData, String]
50
- def markdown_text(match)
51
- if match.is_a?(String)
52
- match = Markdown::FORMATS.match(match)
53
- Aspera.assert(match, 'markdown text does not match any known format')
54
- end
55
- Aspera.assert_type(match, MatchData)
56
- if match[:entity]
57
- Aspera.assert_values(match[:entity], %w[bsol])
58
- '\\'
59
- elsif match[:bold]
60
- match[:bold].to_s.blue
61
- elsif match[:code]
62
- match[:code].to_s.bold
63
- else
64
- Aspera.error_unexpected_value(match.to_s)
36
+ # Convert Markdown inline markup to terminal format (**bold** -> blue, `code` -> bold)
37
+ # @param text [String] text with Markdown inline markup
38
+ # @return [String] text with terminal formatting
39
+ def markdown_text(text)
40
+ text.to_s.gsub(Markdown::INLINE_MARKUP_RE) do
41
+ match = Regexp.last_match
42
+ if match[:entity]
43
+ case match[:entity]
44
+ when 'bsol' then '\\'
45
+ else Aspera.error_unexpected_value(match[:entity]) { 'HTML entity' }
46
+ end
47
+ elsif match[:bold]
48
+ match[:bold].to_s.blue
49
+ elsif match[:code]
50
+ match[:code].to_s.bold
51
+ else
52
+ Aspera.error_unexpected_value(match.to_s)
53
+ end
65
54
  end
66
55
  end
67
56
 
68
- module_function :hint, :tick, :special_format, :check_row, :markdown_text
57
+ module_function :hint, :tick, :special_format, :markdown_text
69
58
  end
70
59
  end
71
60
  end
@@ -9,6 +9,7 @@ require 'aspera/cli/info'
9
9
  require 'aspera/cli/async_transfer_store'
10
10
  require 'aspera/log'
11
11
  require 'aspera/assert'
12
+ require 'aspera/secret_hider'
12
13
  require 'securerandom'
13
14
 
14
15
  module Aspera
@@ -252,11 +253,13 @@ module Aspera
252
253
  # updated transfer spec with command line
253
254
  transfer_spec.deep_merge!(@user_transfer_spec)
254
255
  # resolve pseudo-parameter: target_rate -> target_rate_kbps (overrides target_rate_kbps if both are present)
255
- transfer_spec['target_rate_kbps'] = Transfer::Spec.rate_string_to_kbps(transfer_spec.delete('target_rate')) if transfer_spec.key?('target_rate')
256
+ Transfer::Spec.resolve_target_rate(transfer_spec)
256
257
  # recursively remove values that are nil (user wants to delete)
257
258
  transfer_spec.deep_do { |hash, key, value, _unused| hash.delete(key) if value.nil? }
258
259
  # if TS from app has content_protection (e.g. F5), that means content is protected: ask password if not provided
259
260
  transfer_spec['content_protection_password'] = @context.options.prompt_user_input('content protection password', sensitive: true) if transfer_spec['content_protection'].eql?('decrypt') && !transfer_spec.key?('content_protection_password')
261
+ # Secrets of transfer spec (e.g. `token` from the application) are hidden wherever they appear (logs, results)
262
+ SecretHider.instance.add_secrets_in(transfer_spec)
260
263
  # create transfer agent
261
264
  agent_instance.start_transfer(transfer_spec, token_regenerator: rest_token)
262
265
  # --- async mode ---