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
@@ -33,11 +33,12 @@ module Aspera
33
33
 
34
34
  # @return [String] value from vault matching <name>.<param>
35
35
  def vault_value(name)
36
- m = name.split('.')
37
- Aspera.assert(m.length.eql?(2), type: BadArgument) { 'vault name shall match <name>.<param>' }
38
- info = vault_required.get(label: m[0])
39
- value = info[m[1].to_sym]
40
- raise "no such entry value: #{m[1]}" if value.nil?
36
+ # Label may contain dots (e.g. preset name)
37
+ label, _, param = name.rpartition('.')
38
+ Aspera.assert(!label.empty? && !param.empty?, type: BadArgument) { 'vault name shall match <name>.<param>' }
39
+ info = vault_required.get(label: label)
40
+ value = info[param.to_sym]
41
+ raise "no such entry value: #{param}" if value.nil?
41
42
  return value
42
43
  end
43
44
 
@@ -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.27.5'
7
+ VERSION = '4.28.0'
8
8
  end
9
9
  end
@@ -0,0 +1,61 @@
1
+ <%# Content of the secret page, in layout of Aspera::WebPage %>
2
+ <% if state.eql?(:form) %>
3
+ <div class="icon-circle">
4
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
5
+ <rect x="6" y="13" width="20" height="15" rx="3"/>
6
+ <path d="M10 13V9a6 6 0 0 1 12 0v4"/>
7
+ <circle cx="16" cy="20.5" r="2" fill="currentColor"/>
8
+ </svg>
9
+ </div>
10
+
11
+ <h1>Secret request</h1>
12
+ <% if requester %>
13
+ <p><%= h name %> needs the secret <span class="client"><%= h prompt %></span> to execute a command requested by the <%= h requester %>.</p>
14
+ <p>The secret is sent to <%= h name %> only: the <%= h requester %> does not see it.</p>
15
+ <% else %>
16
+ <p><%= h name %> needs the secret <span class="client"><%= h prompt %></span> to execute your command.</p>
17
+ <% end %>
18
+ <% if error %>
19
+ <p class="error"><%= h error %></p>
20
+ <% end %>
21
+
22
+ <form method="POST" action="<%= h action_url %>">
23
+ <% if access_code_required %>
24
+ <label class="secret">Server access code
25
+ <input type="password" name="access_code" autocomplete="off" required autofocus>
26
+ </label>
27
+ <% end %>
28
+ <label class="secret">Secret: <%= h prompt %>
29
+ <input type="password" name="secret" autocomplete="off" required<%= ' autofocus' unless access_code_required %>>
30
+ </label>
31
+ <div class="actions">
32
+ <button class="allow" type="submit" name="decision" value="submit">Submit</button>
33
+ <button class="deny" type="submit" name="decision" value="cancel" formnovalidate>Cancel</button>
34
+ </div>
35
+ </form>
36
+ <% elsif state.eql?(:received) %>
37
+ <div class="icon-circle ok">
38
+ <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
39
+ <polyline points="6 16 13 23 26 9"/>
40
+ </svg>
41
+ </div>
42
+
43
+ <h1>Secret received</h1>
44
+ <p>You can close this window and return to your <%= h(requester || 'terminal') %>.</p>
45
+ <% else %>
46
+ <div class="icon-circle ko">
47
+ <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
48
+ <line x1="9" y1="9" x2="23" y2="23"/>
49
+ <line x1="23" y1="9" x2="9" y2="23"/>
50
+ </svg>
51
+ </div>
52
+
53
+ <% if state.eql?(:cancelled) %>
54
+ <h1>Secret request cancelled</h1>
55
+ <p>The command will fail. You can close this window and return to your <%= h(requester || 'terminal') %>.</p>
56
+ <% else %>
57
+ <h1>Unknown secret request</h1>
58
+ <p>This request is unknown, expired, or already answered.</p>
59
+ <% end %>
60
+ <% end %>
61
+
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore urlsafe
4
+
5
+ require 'aspera/log'
6
+ require 'aspera/assert'
7
+ require 'aspera/environment'
8
+ require 'aspera/web_page'
9
+ require 'aspera/servlet_helpers'
10
+ require 'aspera/cli/error'
11
+ require 'aspera/cli/info'
12
+ require 'openssl'
13
+ require 'securerandom'
14
+
15
+ module Aspera
16
+ module Cli
17
+ # Secrets entered by the user on a web page, instead of the terminal.
18
+ # Used by extended value `@secret:` when option `ui` is `graphical`, and in MCP server mode (see Mcp::Secrets).
19
+ #
20
+ # Requests are kept in memory, identified by a random value in the URL of the page, and expire after TTL seconds.
21
+ # Pages are served by a local web server, started on first request, or by the web server of the caller (see #handle).
22
+ class WebSecret
23
+ # Lifetime of a request in seconds: the user shall enter the secret within this time
24
+ TTL = 600
25
+ # Path of the page, followed by the request identifier
26
+ PATH_PREFIX = '/secret/'
27
+ # Interval in seconds between calls to the block while waiting for the user
28
+ WAIT_SLICE = 10
29
+ # Address of the local web server
30
+ LOCAL_ADDRESS = '127.0.0.1'
31
+ # Maximum time in seconds to wait for the local web server to send the last page on shutdown
32
+ SHUTDOWN_TIMEOUT = 5
33
+ # ERB template of the page
34
+ HTML_TEMPLATE_PATH = File.join(__dir__, 'web_secret.html.erb')
35
+ # A request for a secret, status is one of: :pending, :received, :cancelled
36
+ Request = Struct.new(:prompt, :status, :value, :expires_at, keyword_init: true)
37
+ private_constant :WAIT_SLICE, :LOCAL_ADDRESS, :SHUTDOWN_TIMEOUT, :HTML_TEMPLATE_PATH, :Request
38
+
39
+ class << self
40
+ # Ask for a secret on a web page, opened in the browser
41
+ # @param prompt [String] Name of the secret
42
+ # @return [String] The secret
43
+ def ask(prompt)
44
+ web_secret = new
45
+ id = web_secret.create(prompt)
46
+ Environment.instance.open_uri(web_secret.url(id))
47
+ web_secret.wait_value(id)
48
+ ensure
49
+ web_secret&.shutdown
50
+ end
51
+ end
52
+
53
+ # @param base_url [String, nil] URL of the web server serving the pages (see #handle), `nil` to start a local web server on first request
54
+ # @param access_code [String, nil] If set, the user must also enter it on the page
55
+ # @param name [String] Name of the application needing the secret, shown on the page
56
+ # @param requester [String, nil] Who requested the command, shown on the page, e.g. `AI assistant`, `nil` for the user on terminal
57
+ def initialize(base_url: nil, access_code: nil, name: Info::CMD_NAME, requester: nil)
58
+ Aspera.assert(access_code.nil? || !access_code.to_s.empty?) { 'access_code must not be empty' }
59
+ @base_url = base_url
60
+ @access_code = access_code&.to_s
61
+ @name = name
62
+ @requester = requester
63
+ @mutex = Mutex.new
64
+ @cond = ConditionVariable.new
65
+ # id => Request
66
+ @requests = {}
67
+ @local_server = nil
68
+ @local_thread = nil
69
+ end
70
+
71
+ # @param prompt [String] Name of the secret, shown to the user
72
+ # @return [String] Identifier of the new request
73
+ def create(prompt)
74
+ id = SecureRandom.urlsafe_base64(24)
75
+ @mutex.synchronize do
76
+ purge_expired
77
+ @requests[id] = Request.new(prompt: prompt, status: :pending, expires_at: Time.now + TTL)
78
+ end
79
+ id
80
+ end
81
+
82
+ # @param id [String] Identifier of the request
83
+ # @return [String] URL of the page where the user enters the secret
84
+ def url(id)
85
+ "#{base_url}#{PATH_PREFIX}#{id}"
86
+ end
87
+
88
+ # @param id [String] Identifier of the request
89
+ # @return [String, nil] Prompt of the request, `nil` if unknown or expired
90
+ def prompt(id)
91
+ @mutex.synchronize { live_request(id)&.prompt }
92
+ end
93
+
94
+ # Wait until the user enters the secret, or cancels, on the page
95
+ # @param id [String] Identifier of the request
96
+ # @yield [elapsed] Called periodically while waiting, with elapsed seconds
97
+ # @return [String] The secret
98
+ # @raise [Cli::Error] if the request is unknown, expired, or cancelled by the user
99
+ def wait_value(id)
100
+ start = Time.now
101
+ loop do
102
+ @mutex.synchronize do
103
+ request = live_request(id)
104
+ raise Error, 'Secret request expired: execute the command again' if request.nil?
105
+ raise Error, 'Secret entry cancelled by user' if request.status.eql?(:cancelled)
106
+ return request.value if request.status.eql?(:received)
107
+ @cond.wait(@mutex, (request.expires_at - Time.now).clamp(0, WAIT_SLICE))
108
+ end
109
+ yield(Time.now - start) if block_given?
110
+ end
111
+ end
112
+
113
+ # Forget requests, and their secrets
114
+ # @param ids [Array<String>] Identifiers of requests
115
+ def delete(ids)
116
+ @mutex.synchronize { ids.each { |id| @requests.delete(id) } }
117
+ end
118
+
119
+ # Stop the local web server, if started, after it sends the last page to the browser
120
+ def shutdown
121
+ return if @local_server.nil?
122
+ @local_server.shutdown
123
+ @local_thread.join(SHUTDOWN_TIMEOUT)
124
+ end
125
+
126
+ # Serve the page: GET shows the form, POST receives the secret or the cancellation
127
+ # @param req [WEBrick::HTTPRequest]
128
+ # @param res [WEBrick::HTTPResponse]
129
+ def handle(req, res)
130
+ require 'webrick'
131
+ id = req.path.delete_prefix(PATH_PREFIX)
132
+ status, page = @mutex.synchronize do
133
+ request = live_request(id)
134
+ if request.nil? || !request.status.eql?(:pending)
135
+ [404, {state: :unknown}]
136
+ elsif req.request_method.eql?('GET')
137
+ [200, {state: :form, prompt: request.prompt}]
138
+ elsif req.request_method.eql?('POST')
139
+ answer(request, WEBrick::HTTPUtils.parse_query(req.body.to_s))
140
+ else
141
+ [405, {state: :unknown}]
142
+ end
143
+ end
144
+ res.status = status
145
+ WebPage.secure_headers(res)
146
+ # Variables of the template, state is one of: :form, :received, :cancelled, :unknown
147
+ res.body = WebPage.render(
148
+ HTML_TEMPLATE_PATH,
149
+ title: 'Secret request',
150
+ name: @name,
151
+ requester: @requester,
152
+ action_url: "#{PATH_PREFIX}#{id}",
153
+ access_code_required: !@access_code.nil?,
154
+ prompt: nil,
155
+ error: nil,
156
+ **page
157
+ )
158
+ end
159
+
160
+ private
161
+
162
+ # Record the answer of the user, called with the mutex locked
163
+ # @param request [Request] The pending request
164
+ # @param params [Hash] Fields of the form
165
+ # @return [Array] HTTP status and attributes of the page
166
+ def answer(request, params)
167
+ if params['decision'].eql?('cancel')
168
+ request.status = :cancelled
169
+ elsif @access_code && !OpenSSL.secure_compare(params['access_code'].to_s, @access_code)
170
+ Log.log.warn('Secret page: wrong access code')
171
+ return [401, {state: :form, prompt: request.prompt, error: 'Invalid access code'}]
172
+ else
173
+ # Form data is binary, the page is UTF-8
174
+ request.value = params['secret'].to_s.dup.force_encoding(Encoding::UTF_8)
175
+ request.status = :received
176
+ end
177
+ @cond.broadcast
178
+ [200, {state: request.status}]
179
+ end
180
+
181
+ # Called with the mutex locked
182
+ # @return [Request, nil] The request, `nil` if unknown or expired
183
+ def live_request(id)
184
+ request = @requests[id]
185
+ request unless request.nil? || request.expires_at < Time.now
186
+ end
187
+
188
+ def purge_expired
189
+ now = Time.now
190
+ @requests.delete_if { |_, request| request.expires_at < now }
191
+ end
192
+
193
+ # @return [String] URL of the server, for the browser
194
+ def base_url
195
+ @mutex.synchronize { @base_url ||= start_local_server }
196
+ end
197
+
198
+ # Start a local web server for the pages
199
+ # @return [String] URL of the local server
200
+ def start_local_server
201
+ require 'aspera/web_server_simple'
202
+ servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
203
+ include ServletHelpers
204
+
205
+ # Refuse requests of other web pages, the WebSecret instance is the option of the servlet
206
+ def service(req, res)
207
+ check_request(req)
208
+ @options.first.handle(req, res)
209
+ end
210
+ end
211
+ # Port 0: any free port
212
+ @local_server = WebServerSimple.new(URI::HTTP.build(host: LOCAL_ADDRESS, port: 0))
213
+ @local_server.mount(PATH_PREFIX, servlet, self)
214
+ # In a thread: signals are not trapped by the server
215
+ @local_thread = Thread.new { @local_server.start }
216
+ @local_server.uri.to_s
217
+ end
218
+ end
219
+ end
220
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aspera/oauth/jwt'
3
+ require 'aspera/oauth/client/jwt'
4
4
  require 'aspera/assert'
5
+ require 'aspera/environment'
6
+ require 'aspera/id_generator'
5
7
  require 'aspera/cli/option_declarator'
6
8
  require 'aspera/cli/plugins/factory'
7
9
  require 'aspera/rainbow'
@@ -13,7 +15,7 @@ module Aspera
13
15
  class Wizard
14
16
  extend OptionDeclarator
15
17
 
16
- WIZARD_RESULT_KEYS = %i[preset_value test_args].freeze
18
+ WIZARD_RESULT_KEYS = %i[preset_ids preset_value test_args].freeze
17
19
  DEFAULT_PRIV_KEY_FILENAME = 'my_private_key.pem' # pragma: allowlist secret
18
20
  private_constant :WIZARD_RESULT_KEYS, :DEFAULT_PRIV_KEY_FILENAME
19
21
 
@@ -84,10 +86,18 @@ module Aspera
84
86
  return found_apps
85
87
  end
86
88
 
89
+ # Display the path to a page of the web UI
90
+ # @param steps [Array<String>] Menus and pages, from the top (e.g. `Environment.char('👤')` for the user menu)
91
+ # @return [nil]
92
+ def display_navigation(*steps)
93
+ formatter.display_status("Navigate to: #{steps.join(" #{Environment.char('→')} ")}")
94
+ nil
95
+ end
96
+
87
97
  # To be called in public wizard method to get private key
88
98
  # @param user [String] User's email
89
99
  # @param url [String] Instance URL
90
- # @param page [String] URL of page to enter pub key
100
+ # @param page [Array<String>] Navigation steps to the page to enter the public key (see `display_navigation`)
91
101
  # @return [String] Private key path (can contain ~ for home)
92
102
  def ask_private_key(user:, url:, page:)
93
103
  # Lets see if path to priv key is provided
@@ -111,7 +121,7 @@ module Aspera
111
121
  pub_key_pem = OpenSSL::PKey::RSA.new(private_key_pem).public_key.to_s
112
122
  options.set_option(:private_key, private_key_pem)
113
123
  formatter.display_status("Please Log in as user #{user.red} at: #{url.red}")
114
- formatter.display_status("Navigate to: #{page}")
124
+ display_navigation(*page)
115
125
  formatter.display_status("Check or update the value to (#{'including BEGIN/END lines'.red}):".blink)
116
126
  formatter.display_status(pub_key_pem, hide_secrets: false)
117
127
  formatter.display_status('Once updated or validated, press [Enter].')
@@ -146,14 +156,14 @@ module Aspera
146
156
  wizard_result = plugin_instance.wizard(self, wiz_url)
147
157
  Log.log.debug { "wizard result: #{wizard_result}" }
148
158
  Aspera.assert(WIZARD_RESULT_KEYS.eql?(wizard_result.keys.sort)) { "missing or extra keys in wizard result: #{wizard_result.keys}" }
159
+ Aspera.assert_array_all(wizard_result[:preset_ids], String) { 'preset_ids' }
149
160
  # Get preset name from user or default
150
161
  if wiz_preset_name.empty?
151
- elements = [
162
+ wiz_preset_name = IdGenerator.from_list(
152
163
  identification[:product],
153
- URI.parse(wiz_url).host
154
- ]
155
- elements.push(options.get_option(:username, mandatory: true)) unless wizard_result[:preset_value].key?(:link) rescue nil
156
- wiz_preset_name = elements.join('_').strip.downcase.gsub(/[^a-z0-9]/, '_').squeeze('_')
164
+ wiz_url,
165
+ *wizard_result[:preset_ids]
166
+ )
157
167
  end
158
168
  # Write configuration file
159
169
  formatter.display_status("Preparing preset: #{wiz_preset_name}")
@@ -31,6 +31,7 @@ module Aspera
31
31
  'x-agents', # [Array] Supported agents (for doc only), if not specified: all
32
32
  'x-ts-name', # [Boolean,String] (async) true if same name in transfer spec, else real name in transfer spec, else ignored
33
33
  'x-ts-convert', # [String] (async) Name of methods to convert value from transfer spec to `conf` API.
34
+ 'x-unit', # [String] (async) Time unit of the integer value in `conf` (e.g. `ms`), command line option is a DURATION
34
35
  'x-deprecation' # [String] Deprecation message for doc
35
36
  ].freeze
36
37
 
@@ -52,6 +53,30 @@ module Aspera
52
53
  !properties.key?('x-agents') || properties['x-agents'].include?(agent.to_s)
53
54
  end
54
55
 
56
+ # Documentation of the command line option of a property: `annotate` of `Schema::Documentation`
57
+ # @param node [Hash] Schema of the property
58
+ # @param formatter [FormatterInterface] Formatter of the documentation
59
+ # @return [Array<String>] Lines added to the description, empty if no option
60
+ def doc_lines(node, formatter)
61
+ envvar_prefix = ''
62
+ cli_option =
63
+ if node.key?('x-cli-envvar')
64
+ envvar_prefix = 'env:'
65
+ node['x-cli-envvar']
66
+ elsif node['x-cli-switch']
67
+ false_part = node.key?('x-cli-false') ? " / #{node['x-cli-false']}" : ''
68
+ "#{node['x-cli-option']}#{false_part}"
69
+ elsif node['x-cli-option']
70
+ arg_type = node.key?('enum') ? '{enum}' : "{#{[node['type']].flatten.join('|')}}"
71
+ conversion_tag = node.key?('x-cli-convert') ? 'conversion' : nil
72
+ sep = node['x-cli-option'].start_with?('--') ? '=' : ' '
73
+ "#{node['x-cli-option']}#{sep}#{"(#{conversion_tag})" if conversion_tag}#{arg_type}"
74
+ end
75
+ return [] unless cli_option
76
+ short = node.key?('x-cli-short') ? "(#{node['x-cli-short']})" : nil
77
+ ["(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{formatter.markdown_text("`#{cli_option}`")})#{short}"]
78
+ end
79
+
55
80
  private
56
81
 
57
82
  # `ascp`: mandatory type
@@ -72,6 +97,7 @@ module Aspera
72
97
  Aspera.assert(node['type'].eql?('boolean')) { "switch must be bool: #{name}" } if node['x-cli-switch'] && !node['x-cli-special']
73
98
  Aspera.assert(node['x-cli-switch']) { "boolean direct option must have x-cli-switch: #{name}" } if node['type'].eql?('boolean') && node.key?('x-cli-option') && supported_by_agent(:direct, node) && !node['x-cli-special']
74
99
  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'))
100
+ Aspera.assert(supported_by_agent(:direct, node)) { "#{name}: x-cli-option requires agent direct (or nil)" } if node['x-cli-option']
75
101
  Aspera.assert(DIRECT_PROPERTIES.any? { |i| node.key?(i) }, name, type: :warn) if ascp && supported_by_agent(:direct, node)
76
102
  node.freeze
77
103
  end
@@ -4,6 +4,7 @@ require 'aspera/assert'
4
4
  require 'singleton'
5
5
  require 'openssl'
6
6
  require 'zlib'
7
+ require 'base64'
7
8
 
8
9
  module Aspera
9
10
  # a simple binary data repository
@@ -14,39 +15,52 @@ module Aspera
14
15
  class DataRepository
15
16
  include Singleton
16
17
 
17
- # in same order as elements in folder
18
- ELEMENTS = %i[dsa rsa uuid aspera.global-cli-client aspera.drive license]
19
- START_INDEX = 1
18
+ # name of data item => name of its file in data folder
19
+ FILES = {
20
+ dsa: '1',
21
+ rsa: '2',
22
+ uuid: '3',
23
+ 'aspera.global-cli-client': '4',
24
+ 'aspera.drive': '5',
25
+ license: '6'
26
+ }.freeze
27
+ # names of data items
28
+ ELEMENTS = FILES.keys.freeze
20
29
  DATA_FOLDER_NAME = 'data'
21
30
 
22
- # decode data as expected as string
23
- # @param name [Symbol] name of the data item
24
- # @return [String] decoded data
31
+ # Get a data item, decoded to its usable form
32
+ # @param name [Symbol] name of the data item, one of ELEMENTS
33
+ # @return [String] PEM private key (dsa, rsa), UUID (uuid), base64url secret (client apps), or text (license)
25
34
  def item(name)
26
- index = ELEMENTS.index(name)
27
- Aspera.assert(index, type: ParameterError) { "Unknown data item #{name} (#{name.class})" }
28
- raw_data = data(START_INDEX + index)
29
- case name
30
- when :dsa, :rsa
31
- # generate PEM from DER
32
- return OpenSSL::PKey.const_get(name.to_s.upcase).new(raw_data).to_pem
33
- when :license
34
- return Zlib::Inflate.inflate(raw_data)
35
- when :uuid
36
- return format('%08x-%04x-%04x-%04x-%04x%08x', *raw_data.unpack('NnnnnN'))
37
- when :'aspera.global-cli-client', :'aspera.drive'
38
- return Base64.urlsafe_encode64(raw_data)
39
- else Aspera.error_unexpected_value(name)
40
- end
35
+ Aspera.assert_values(name, ELEMENTS, type: ParameterError) { 'data item' }
36
+ raw_data = data(name)
37
+ value =
38
+ case name
39
+ when :dsa, :rsa
40
+ # generate PEM from DER
41
+ # Class of key, not `OpenSSL::PKey.read`: on JRuby (jruby-openssl 0.16), it fails on these keys (DER, traditional format)
42
+ {dsa: OpenSSL::PKey::DSA, rsa: OpenSSL::PKey::RSA}[name].new(raw_data).to_pem
43
+ when :license
44
+ Zlib::Inflate.inflate(raw_data)
45
+ when :uuid
46
+ format('%08x-%04x-%04x-%04x-%04x%08x', *raw_data.unpack('NnnnnN'))
47
+ when :'aspera.global-cli-client', :'aspera.drive'
48
+ Base64.urlsafe_encode64(raw_data)
49
+ else Aspera.error_unexpected_value(name)
50
+ end
51
+ # all items are text
52
+ return value.force_encoding(Encoding::UTF_8)
41
53
  end
42
54
 
43
55
  private
44
56
 
45
- private_constant :START_INDEX, :DATA_FOLDER_NAME
57
+ private_constant :FILES, :DATA_FOLDER_NAME
46
58
 
47
59
  # get binary value from data repository
48
- def data(id)
49
- File.read(File.join(__dir__, DATA_FOLDER_NAME, id.to_s), mode: 'rb')
60
+ # @param name [Symbol] name of the data item
61
+ # @return [String] raw binary data
62
+ def data(name)
63
+ File.binread(File.join(__dir__, DATA_FOLDER_NAME, FILES[name]))
50
64
  end
51
65
  end
52
66
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/assert'
4
+ require 'strscan'
5
+
6
+ module Aspera
7
+ # Duration in the format of Aspera Sync (`async` DURATION)
8
+ module Duration
9
+ # Microseconds per unit
10
+ UNIT_US = {'w' => 604_800_000_000, 'd' => 86_400_000_000, 'h' => 3_600_000_000, 'm' => 60_000_000, 's' => 1_000_000, 'ms' => 1_000, 'us' => 1}.freeze
11
+ private_constant :UNIT_US
12
+
13
+ class << self
14
+ # Convert a duration to an integer in the given time unit
15
+ # @param value [String] `DDd HH:MM:SS.mmm` or `NNw NNd NNh NNm NNs NNms NNus`, leading fields and spaces may be omitted, a plain number is seconds
16
+ # @param unit [String] Time unit of result, e.g. `ms`
17
+ # @return [Integer] Duration in `unit`, rounded down
18
+ def to_unit(value, unit)
19
+ Aspera.assert_values(unit, UNIT_US.keys) { 'duration unit' }
20
+ value = value.to_s.delete(' ')
21
+ if (m = value.match(/\A(?:(\d+)d)?(?:(?:(\d+):)?(\d+):)?(\d+)(?:\.(\d{1,3}))?\z/))
22
+ days, hours, minutes, seconds, millis = m.captures
23
+ hours = (days.to_i * 24) + hours.to_i
24
+ minutes = (hours * 60) + minutes.to_i
25
+ seconds = (minutes * 60) + seconds.to_i
26
+ microseconds = (seconds * UNIT_US['s']) + (millis.to_s.ljust(3, '0').to_i * UNIT_US['ms'])
27
+ else
28
+ # anchored sequential matching (linear time, no backtracking over start positions)
29
+ scanner = StringScanner.new(value)
30
+ units = []
31
+ until scanner.eos?
32
+ Aspera.assert(scanner.scan(/(\d+)(ms|us|[wdhms])/), type: ParameterError) { "Invalid duration: #{value}" }
33
+ units.push([scanner[1], scanner[2]])
34
+ end
35
+ Aspera.assert(!units.empty?, type: ParameterError) { "Invalid duration: #{value}" }
36
+ microseconds = units.sum { |number, number_unit| number.to_i * UNIT_US[number_unit] }
37
+ end
38
+ microseconds / UNIT_US[unit]
39
+ end
40
+ end
41
+ end
42
+ end