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
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'uri'
6
+ require 'webrick'
7
+ require 'aspera/mime'
8
+ require 'aspera/web_server_simple'
9
+
10
+ module Aspera
11
+ # Mixin for WEBrick servlets of local web servers: JSON responses, and check of requests that a web page could send.
12
+ #
13
+ # A web page open in the browser of the user can send requests to a server that the browser reaches, also on localhost:
14
+ # - Cross-site request forgery: a form, or a `fetch` with a CORS-safelisted `Content-Type` (`text/plain`, form), is sent cross-origin without preflight.
15
+ # - DNS rebinding: a host name of the attacker that resolves to `127.0.0.1` makes the page same-origin with the server, so it also reads responses.
16
+ module ServletHelpers
17
+ class << self
18
+ # @param url [String] URL
19
+ # @return [String] Origin of the URL, as in header `Origin`, e.g. `https://example.com:8443`
20
+ def origin(url)
21
+ uri = URI.parse(url)
22
+ port = ":#{uri.port}" unless uri.port.eql?(uri.default_port)
23
+ "#{uri.scheme}://#{uri.host}#{port}".downcase
24
+ end
25
+
26
+ # @param host [String] Value of header `Host`, e.g. `[::1]:8080`
27
+ # @return [String] Host name, without port and IPv6 brackets
28
+ def host_name(host)
29
+ return host[/\A\[([^\]]*)\]/, 1].to_s if host.start_with?('[')
30
+ host.split(':').first.to_s
31
+ end
32
+
33
+ # Scheme is not compared: header `Host` has none
34
+ # @param origin [String] Value of header `Origin`, e.g. `http://localhost:8080`
35
+ # @param host [String] Value of header `Host`, e.g. `localhost:8080`
36
+ # @return [Boolean] `true` if the origin is the one of the requested server
37
+ def same_origin?(origin, host)
38
+ default_port = origin.start_with?('https://') ? ':443' : ':80'
39
+ origin.sub(%r{\Ahttps?://}, '').delete_suffix(default_port).eql?(host.delete_suffix(default_port))
40
+ end
41
+ end
42
+
43
+ # Set a JSON response
44
+ # @param response [WEBrick::HTTPResponse]
45
+ # @param status [Integer] HTTP status code
46
+ # @param body [Hash, Array] Body of the response
47
+ # @param headers [Hash{String => String}] Other headers of the response, e.g. `Cache-Control`
48
+ # @return [nil]
49
+ def json_response(response, status, body, headers: {})
50
+ response.status = status
51
+ response['Content-Type'] = Mime::JSON
52
+ headers.each { |name, value| response[name] = value }
53
+ response.body = JSON.generate(body)
54
+ nil
55
+ end
56
+
57
+ # Set a JSON error response, in the format of OAuth 2.0 (RFC 6749 §5.2)
58
+ # @param response [WEBrick::HTTPResponse]
59
+ # @param status [Integer] HTTP status code
60
+ # @param error [String] Error code or message
61
+ # @param description [String, nil] Details
62
+ # @param headers [Hash{String => String}] Other headers of the response, e.g. `WWW-Authenticate`
63
+ # @return [nil]
64
+ def json_error(response, status, error, description = nil, headers: {})
65
+ body = {error: error}
66
+ body[:error_description] = description unless description.nil?
67
+ json_response(response, status, body, headers: headers)
68
+ end
69
+
70
+ # @param request [WEBrick::HTTPRequest]
71
+ # @param scheme [String] Authentication scheme, e.g. `Basic` or `Bearer`
72
+ # @return [String, nil] Credentials of header `Authorization` if it has this scheme, else `nil`
73
+ def authorization(request, scheme)
74
+ request_scheme, credentials = request['Authorization'].to_s.split(' ', 2)
75
+ credentials.to_s if request_scheme.to_s.casecmp?(scheme)
76
+ end
77
+
78
+ # Check the credentials of HTTP Basic authentication, in constant time
79
+ # @param request [WEBrick::HTTPRequest]
80
+ # @param expected [String] Expected `username:password`
81
+ # @return [Boolean] `true` if the request has the expected credentials
82
+ def basic_authorized?(request, expected)
83
+ OpenSSL.secure_compare(authorization(request, 'Basic').to_s.unpack1('m').to_s, expected)
84
+ end
85
+
86
+ # Refuse a request that a web page in a browser could send. Clients that are not browsers send no `Origin`, and the `Host` of the URL they call.
87
+ # - `Host`: when the server listens on loopback, a loopback name or address, or one of `allowed_hosts` (DNS rebinding)
88
+ # - `Origin`: when present, the origin of the server, or one of `allowed_origins` (cross-site request)
89
+ # - `Content-Type`: with `json`, a JSON type, which a browser sends cross-origin only after a preflight request `OPTIONS`, not answered (cross-site request)
90
+ # @param request [WEBrick::HTTPRequest]
91
+ # @param json [Boolean] `true` if the body, if any, is parsed as JSON
92
+ # @param allowed_hosts [Array<String>] Other values accepted in `Host`: host name, or `host:port`
93
+ # @param allowed_origins [Array<String>] Other values accepted in `Origin`, e.g. `https://example.com`
94
+ # @raise [WEBrick::HTTPStatus::Forbidden] if `Host` or `Origin` is refused
95
+ # @raise [WEBrick::HTTPStatus::UnsupportedMediaType] if the body is not JSON
96
+ def check_request(request, json: false, allowed_hosts: [], allowed_origins: [])
97
+ host = request['Host']&.downcase
98
+ if host && WebServerSimple.loopback?(@server[:BindAddress].to_s)
99
+ name = ServletHelpers.host_name(host)
100
+ host_allowed = WebServerSimple.loopback?(name) || allowed_hosts.any? { |allowed_host| [name, host].include?(allowed_host.downcase) }
101
+ raise WEBrick::HTTPStatus::Forbidden, "Host is not a loopback address: #{host}" unless host_allowed
102
+ end
103
+ origin = request['Origin']&.downcase
104
+ origin_allowed = origin.nil? || (host && ServletHelpers.same_origin?(origin, host)) || allowed_origins.any? { |allowed_origin| allowed_origin.downcase.eql?(origin) }
105
+ raise WEBrick::HTTPStatus::Forbidden, "Origin is not the one of the server: #{origin}" unless origin_allowed
106
+ return unless json && !request.body.to_s.empty?
107
+ media_type = request['Content-Type'].to_s.split(';').first.to_s.strip.downcase
108
+ raise WEBrick::HTTPStatus::UnsupportedMediaType, "Content-Type shall be #{Mime::JSON}: #{media_type}" unless Mime.json?(media_type)
109
+ end
110
+ end
111
+ end
@@ -77,8 +77,10 @@ properties:
77
77
  type: string
78
78
  x-cli-option: true
79
79
  target_rate:
80
- type: string
80
+ type: integer
81
81
  x-cli-option: true
82
+ x-ts-name: target_rate_kbps
83
+ x-ts-convert: kbps_to_bps
82
84
  cooloff:
83
85
  type: integer
84
86
  x-cli-option: true
@@ -6,6 +6,7 @@ $comment: >-
6
6
  It is modified with special `x-` fields.
7
7
  x-ts-name [Boolean,String] `true` if same name in transfer spec, else real name in transfer spec
8
8
  x-ts-convert [String] Name of methods to convert value from transfer spec to `conf`
9
+ x-unit [String] Time unit of the integer value (e.g. `ms`), the command line option is a DURATION
9
10
  `x-` fields are also documented in `command_line_builder.rb`
10
11
  type: object
11
12
  required:
@@ -163,14 +164,14 @@ properties:
163
164
  x-cli-switch: true
164
165
  ignore_mode:
165
166
  description:
166
- Source files that have had their mode changed after the initial.
167
+ Source files that have had their mode changed after the initial
167
168
  transfer will not update the destination file mode.
168
169
  type: boolean
169
170
  default: false
170
171
  x-cli-option: true
171
172
  x-cli-switch: true
172
173
  ignore_remote_host_sync_name:
173
- description: Do not check that the remote host being used for the current.
174
+ description: Do not check that the remote host being used for the current
174
175
  transfer matches the host used when the local database was created
175
176
  type: boolean
176
177
  default: false
@@ -260,10 +261,14 @@ properties:
260
261
  local_scan_interval_milliseconds:
261
262
  description:
262
263
  Enable periodic scans on the local host during a continuous sync.
263
- `-1` for disabled
264
+ `-1` for disabled.
265
+ On the command line, the value is a duration, e.g. `1h 30m`, `2s500ms`, `5:30`, `1d 02:00:00.250`.
266
+ A plain number is in seconds.
267
+ Stored in milliseconds.
264
268
  type: integer
265
269
  default: -1
266
270
  x-cli-option: --scan-interval
271
+ x-unit: ms
267
272
  local_scan_threads:
268
273
  description: Number of directory scanning threads on the local host.
269
274
  Value must be between 1 and 99
@@ -506,8 +511,8 @@ properties:
506
511
  description: Authenticate to the proxy with the specified password.
507
512
  type: string
508
513
  port:
509
- description: Use the specified port, default is 9091 for dnat, 9092.
510
- for dnats
514
+ description: Use the specified port.
515
+ Default is 9091 for dnat, 9092 for dnats.
511
516
  type: integer
512
517
  protocol:
513
518
  description: The protocol to be used.
@@ -602,10 +607,14 @@ properties:
602
607
  remote_scan_interval_milliseconds:
603
608
  description: Enable periodic scans on the remote host.
604
609
  `-1` for disabled.
610
+ On the command line, the value is a duration, e.g. `1h 30m`, `2s500ms`, `5:30`, `1d 02:00:00.250`.
611
+ A plain number is in seconds.
612
+ Stored in milliseconds.
605
613
  type: integer
606
614
  default: -1
607
615
  x-cli-option: --remote-scan-interval
608
616
  x-cli-special: true
617
+ x-unit: ms
609
618
  remote_scan_threads:
610
619
  description: Number of directory scanning threads on the remote host.
611
620
  Value must be between 1 and 99.
@@ -665,7 +674,7 @@ properties:
665
674
  x-cli-option: true
666
675
  x-cli-switch: true
667
676
  scan_dir_rename:
668
- description: Enable the detection of renamed directories and files compared.
677
+ description: Enable the detection of renamed directories and files compared
669
678
  to the previous scan, based on matching inodes
670
679
  type: boolean
671
680
  default: false
@@ -806,6 +815,7 @@ properties:
806
815
  x-cli-short: -X
807
816
  target_rate:
808
817
  description: Transfer no faster than the specified rate (in bps).
818
+ A string with suffix `k`, `m` or `g` is converted to bps by `ascli` (e.g. `100m`).
809
819
  type: integer
810
820
  default: 10000000
811
821
  x-ts-name: target_rate_kbps
@@ -4,11 +4,13 @@
4
4
 
5
5
  require 'aspera/ascp/installation'
6
6
  require 'aspera/agent/direct'
7
+ require 'aspera/transfer/spec'
7
8
  require 'aspera/exec_spec'
8
9
  require 'aspera/command_line_converter'
9
10
  require 'aspera/command_line_builder'
10
11
  require 'aspera/log'
11
12
  require 'aspera/assert'
13
+ require 'aspera/duration'
12
14
  require 'aspera/environment'
13
15
  require 'aspera/schema/registry'
14
16
  require 'json'
@@ -98,11 +100,13 @@ module Aspera
98
100
  remote = sync_info['remote']
99
101
  Aspera.assert_type(remote, Hash) { 'remote' }
100
102
  Aspera.assert_type(remote['path'], String) { 'remote path' }
103
+ normalize_target_rate(sync_info['transport'])
101
104
  # get transfer spec if possible, and feed back to new structure
102
105
  if block_given?
103
106
  transfer_spec = yield(direction_sym(sync_info), sync_info['local']['path'], remote['path'])
104
107
  Log.dump(:auth_ts, transfer_spec)
105
108
  transfer_spec.deep_merge!(opt_ts) unless opt_ts.nil?
109
+ Transfer::Spec.resolve_target_rate(transfer_spec)
106
110
  tspec_to_sync_info(transfer_spec, sync_info, CONF_SCHEMA)
107
111
  update_remote_dir(remote, 'path', transfer_spec)
108
112
  end
@@ -123,6 +127,7 @@ module Aspera
123
127
  sync_info.keys.push('instance').uniq.sort.eql?(CMDLINE_PARAMS_KEYS)
124
128
  Aspera.assert_type(sync_info['sessions'], Array)
125
129
  Aspera.assert_type(sync_info['sessions'].first, Hash)
130
+ sync_info['sessions'].each { |session| normalize_target_rate(session) }
126
131
  if block_given?
127
132
  sync_info['sessions'].each do |session|
128
133
  Aspera.assert_type(session['local_dir'], String) { 'local_dir' }
@@ -130,6 +135,7 @@ module Aspera
130
135
  transfer_spec = yield(direction_sym(session), session['local_dir'], session['remote_dir'])
131
136
  Log.dump(:auth_ts, transfer_spec)
132
137
  transfer_spec.deep_merge!(opt_ts) unless opt_ts.nil?
138
+ Transfer::Spec.resolve_target_rate(transfer_spec)
133
139
  tspec_to_sync_info(transfer_spec, session, ARGS_SESSION_SCHEMA)
134
140
  session['private_key_paths'] = Ascp::Installation.instance.aspera_token_ssh_key_paths(:rsa) if transfer_spec.key?('token')
135
141
  update_remote_dir(session, 'remote_dir', transfer_spec)
@@ -257,6 +263,12 @@ module Aspera
257
263
 
258
264
  # private
259
265
 
266
+ # Convert `target_rate` given as a string with optional suffix (e.g. `100m`) into integer bps
267
+ # @param params [Hash, nil] `transport` (`conf` format) or session (`args` format), modified in place
268
+ def normalize_target_rate(params)
269
+ params['target_rate'] = Transfer::Spec.rate_string_to_bps(params['target_rate']) if params.is_a?(Hash) && params['target_rate'].is_a?(String)
270
+ end
271
+
260
272
  # Transfer specification to synchronization information
261
273
  # tag `x-ts-name` in schema is used to map transfer spec parameters to async `sync_info`
262
274
  # @param transfer_spec [Hash] transfer specification
@@ -330,7 +342,15 @@ module Aspera
330
342
  current[key] ||= {}
331
343
  current = current[key]
332
344
  end
333
- current[last_key] = props['x-cli-switch'] ? true : args.shift
345
+ current[last_key] =
346
+ if props['x-cli-switch']
347
+ # a special switch sets the non-default enum value, e.g. `--continuous` sets `mode` to `continuous`
348
+ props.key?('enum') ? (props['enum'] - [props['default']]).first : true
349
+ elsif props.key?('x-unit')
350
+ Duration.to_unit(args.shift, props['x-unit'])
351
+ else
352
+ args.shift
353
+ end
334
354
  end
335
355
  return result
336
356
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/assert'
4
+ require 'aspera/log'
3
5
  require 'singleton'
4
6
  require 'fileutils'
5
7
  require 'etc'
@@ -15,21 +17,34 @@ module Aspera
15
17
  class TempFileManager
16
18
  include Singleton
17
19
 
18
- SEC_IN_DAY = 86_400
19
- # assume no transfer last longer than this
20
- # (garbage collect file list which were not deleted after transfer)
21
- FILE_LIST_AGE_MAX_SEC = SEC_IN_DAY * 5
22
- private_constant :SEC_IN_DAY, :FILE_LIST_AGE_MAX_SEC
20
+ # Assume no transfer lasts longer than this
21
+ # (garbage collect file lists which were not deleted after transfer)
22
+ EXPIRY_DEFAULT_SEC = 5 * 86_400
23
+ private_constant :EXPIRY_DEFAULT_SEC
23
24
 
25
+ # @return [Boolean] `true`: files created by this execution are deleted by `cleanup`
24
26
  attr_accessor :cleanup_on_exit
27
+ # @return [Numeric, nil] Age in seconds of files deleted by `cleanup_expired`, `nil`: never deleted
28
+ attr_reader :expiry_sec
25
29
  attr_reader :global_temp
26
30
 
27
31
  def initialize
28
32
  @created_files = []
33
+ # Number of holders of a created file: not deleted by cleanup while held
34
+ @hold_counts = {}
35
+ # Files are created and released by transfer threads
36
+ @mutex = Mutex.new
29
37
  @cleanup_on_exit = true
38
+ @expiry_sec = EXPIRY_DEFAULT_SEC
30
39
  @global_temp = Etc.systmpdir
31
40
  end
32
41
 
42
+ # @param value [Numeric, nil] Age in seconds, `0`: all files, `nil`: never delete
43
+ def expiry_sec=(value)
44
+ Aspera.assert(value.nil? || (value.is_a?(Numeric) && !value.negative?), type: ParameterError) { "Expiry must be a positive duration: #{value}" }
45
+ @expiry_sec = value
46
+ end
47
+
33
48
  def global_temp=(value)
34
49
  @global_temp = case value
35
50
  when '@env' then Dir.tmpdir
@@ -39,17 +54,45 @@ module Aspera
39
54
  end
40
55
 
41
56
  def delete_file(filepath)
42
- File.delete(filepath) if @cleanup_on_exit
43
- rescue => e
44
- Log.log.warn { "Problem deleting file: #{filepath}: #{e.message}" }
57
+ remove_file(filepath) if @cleanup_on_exit
45
58
  end
46
59
 
47
- # Call this on process exit
60
+ # Call this on process exit, or at the end of a command.
61
+ # Held files are kept: they are deleted when released.
48
62
  def cleanup
49
- @created_files.each do |filepath|
50
- delete_file(filepath) if File.file?(filepath)
63
+ @mutex.synchronize do
64
+ @created_files.each do |filepath|
65
+ delete_file(filepath) if !@hold_counts.key?(filepath) && File.file?(filepath)
66
+ end
67
+ @created_files.select! { |filepath| @hold_counts.key?(filepath) }
68
+ end
69
+ end
70
+
71
+ # Keep created files after `cleanup`, until released, for example a file list used by a transfer
72
+ # that ends after the command (asynchronous transfer).
73
+ # A file can be held several times (multi-session): it is deleted when the last holder releases it.
74
+ # @param paths [Array<String>] Files created by this manager (others are ignored)
75
+ def hold(paths)
76
+ @mutex.synchronize do
77
+ paths.each do |filepath|
78
+ @hold_counts[filepath] = @hold_counts.fetch(filepath, 0) + 1 if @created_files.include?(filepath)
79
+ end
80
+ end
81
+ end
82
+
83
+ # Release files held with `hold`, and delete those that are no longer held.
84
+ # @param paths [Array<String>] Files given to `hold`
85
+ def release(paths)
86
+ @mutex.synchronize do
87
+ paths.each do |filepath|
88
+ next unless @hold_counts.key?(filepath)
89
+ @hold_counts[filepath] -= 1
90
+ next if @hold_counts[filepath].positive?
91
+ @hold_counts.delete(filepath)
92
+ @created_files.delete(filepath)
93
+ delete_file(filepath) if File.file?(filepath)
94
+ end
51
95
  end
52
- @created_files = []
53
96
  end
54
97
 
55
98
  # Ensure that provided folder exists, or create it, atomically create and reserve a unique file.
@@ -63,7 +106,7 @@ module Aspera
63
106
  new_file = Dir::Tmpname.create([basename, suffix ? "-#{suffix}" : ''], temp_folder) do |path|
64
107
  File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { nil }
65
108
  end
66
- @created_files.push(new_file)
109
+ @mutex.synchronize { @created_files.push(new_file) }
67
110
  new_file
68
111
  end
69
112
 
@@ -93,17 +136,33 @@ module Aspera
93
136
  File.join(@global_temp, dir_name)
94
137
  end
95
138
 
96
- # Garbage collect undeleted files
139
+ # Garbage collect files not deleted by previous executions (e.g. killed), older than `expiry_sec`.
140
+ # Not disabled by `cleanup_on_exit`, which applies to files of this execution.
141
+ # @param temp_folder [String] Folder that contains only temporary files
97
142
  def cleanup_expired(temp_folder)
98
- Dir.entries(temp_folder).each do |name|
143
+ return if @expiry_sec.nil?
144
+ now = Time.now
145
+ Dir.children(temp_folder).each do |name|
99
146
  file_path = File.join(temp_folder, name)
100
- age_sec = (Time.now - File.stat(file_path).mtime).to_i
101
147
  # check age of file, delete too old
102
- if File.file?(file_path) && (age_sec > FILE_LIST_AGE_MAX_SEC)
103
- Log.log.debug { "garbage collecting #{name}" }
104
- delete_file(file_path)
105
- end
148
+ next unless File.file?(file_path) && (now - File.mtime(file_path)) >= @expiry_sec
149
+ Log.log.debug { "garbage collecting #{name}" }
150
+ remove_file(file_path)
151
+ rescue Errno::ENOENT
152
+ # deleted meanwhile by another execution
153
+ nil
106
154
  end
107
155
  end
156
+
157
+ private
158
+
159
+ # Delete a file, a failure is logged
160
+ def remove_file(filepath)
161
+ File.delete(filepath)
162
+ rescue Errno::ENOENT
163
+ nil
164
+ rescue => e
165
+ Log.log.warn { "Problem deleting file: #{filepath}: #{e.message}" }
166
+ end
108
167
  end
109
168
  end
@@ -80,6 +80,8 @@ module Aspera
80
80
  Aspera.assert_values(@client_ssh_key, Ascp::Installation::CLIENT_SSH_KEY_OPTIONS)
81
81
  @check_ignore_cb = check_ignore_cb
82
82
  @builder = CommandLineBuilder.new(@job_spec, Spec::SCHEMA, CommandLineConverter)
83
+ # temporary files used by `ascp`: given to the agent in the ExecSpec
84
+ @temp_files = []
83
85
  end
84
86
 
85
87
  # Place source files on command line or in a temp file list, controlled by @file_list and file_list_folder
@@ -112,6 +114,7 @@ module Aspera
112
114
  lines = ts_paths_array.map { |i| i['source'] }
113
115
  end
114
116
  file_list_file = TempFileManager.instance.new_file_path_in_folder(self.class.file_list_folder)
117
+ @temp_files.push(file_list_file)
115
118
  Log.dump(:file_list, lines)
116
119
  File.write(file_list_file, lines.join("\n"), encoding: 'UTF-8')
117
120
  Log.log.debug { "#{file_list_option}=\n#{File.read(file_list_file)}".red }
@@ -137,6 +140,7 @@ module Aspera
137
140
  # ignore cert for wss ?
138
141
  if @check_ignore_cb&.call(@job_spec['remote_host'], @job_spec['wss_port'])
139
142
  wss_cert_file = TempFileManager.instance.new_file_path_global('wss_cert')
143
+ @temp_files.push(wss_cert_file)
140
144
  wss_url = "https://#{@job_spec['remote_host']}:#{@job_spec['wss_port']}"
141
145
  File.write(wss_cert_file, Rest.remote_certificate_chain(wss_url))
142
146
  # place in front, as more priority
@@ -213,6 +217,7 @@ module Aspera
213
217
  end
214
218
  # disable redis in client, only for ascp, this makes ascp4 fail
215
219
  exec_spec.env['ASPERA_TEST_REDIS_DISABLE'] = 'true' if exec_spec.exec.eql?(:ascp)
220
+ exec_spec.temp_files.concat(@temp_files)
216
221
  Log.log.debug { "ascp args: #{exec_spec}" }
217
222
  return exec_spec
218
223
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/agent/factory'
3
4
  require 'aspera/command_line_builder'
4
5
  require 'aspera/assert'
5
6
 
@@ -27,6 +28,8 @@ module Aspera
27
28
  TAG_RESERVED = 'aspera'
28
29
  # Specific fields to not display
29
30
  SPECIFIC = %w{token paths direction source_root destination_root}.freeze
31
+ # Value of agent for documentation with one column per agent
32
+ DOC_OVERVIEW = :overview
30
33
  class << self
31
34
  # wrong def in transferd
32
35
  POLICY_FIX = {
@@ -35,13 +38,13 @@ module Aspera
35
38
  'sparse_csum' => 'sparse_checksum',
36
39
  'full_csum' => 'full_checksum'
37
40
  }
38
- # Multipliers for target_rate suffix (result is in kbps)
39
- RATE_SUFFIX_KBPS = {
40
- 'k' => 1,
41
- 'm' => 1_000,
42
- 'g' => 1_000_000
41
+ # Multipliers for rate suffix (result is in bps)
42
+ RATE_SUFFIX_BPS = {
43
+ 'k' => 1_000,
44
+ 'm' => 1_000_000,
45
+ 'g' => 1_000_000_000
43
46
  }.freeze
44
- private_constant :POLICY_FIX, :RATE_SUFFIX_KBPS
47
+ private_constant :POLICY_FIX, :RATE_SUFFIX_BPS
45
48
  # translate upload/download to send/receive
46
49
  def transfer_type_to_direction(transfer_type)
47
50
  XFER_TYPE_TO_DIR.fetch(transfer_type)
@@ -52,21 +55,58 @@ module Aspera
52
55
  XFER_DIR_TO_TYPE.fetch(direction)
53
56
  end
54
57
 
55
- # Parse a human-readable rate string (as accepted by ascp -l) into an integer kbps value.
56
- # Accepted: plain integer (kbps), or integer + suffix k/K (kbps), m/M (x1000 kbps), g/G (x1000000 kbps).
57
- # @param value [String] e.g. "100m", "500000", "1g"
58
- # @return [Integer] value in kbps
59
- def rate_string_to_kbps(value)
58
+ # Parse a human-readable rate (as accepted by async -l) into an integer bps value.
59
+ # Accepted: plain integer (bps), or integer + suffix k/K (x1000), m/M (x1000000), g/G (x1000000000).
60
+ # @param value [String, Integer] e.g. "100m", "500000", "1g", 500000
61
+ # @return [Integer] value in bps
62
+ def rate_string_to_bps(value)
60
63
  m = value.to_s.strip.match(/\A(\d+)([kKmMgG])?\z/)
61
64
  Aspera.assert(m) { "Invalid rate value: #{value.inspect}. Expected integer with optional suffix k/K, m/M or g/G." }
62
- multiplier = m[2] ? RATE_SUFFIX_KBPS.fetch(m[2].downcase) : 1
65
+ multiplier = m[2] ? RATE_SUFFIX_BPS.fetch(m[2].downcase) : 1
63
66
  return m[1].to_i * multiplier
64
67
  end
65
68
 
69
+ # Resolve pseudo-parameter `target_rate` (bps) into `target_rate_kbps` (rounded down), overriding it if both are present.
70
+ # @param transfer_spec [Hash] Transfer spec, modified in place
71
+ def resolve_target_rate(transfer_spec)
72
+ return unless transfer_spec.key?('target_rate')
73
+ rate = transfer_spec.delete('target_rate')
74
+ transfer_spec['target_rate_kbps'] = rate_string_to_bps(rate) / 1000 unless rate.nil?
75
+ end
76
+
66
77
  def fix_transferd_resume_policy(transfer_spec)
67
78
  # Fix discrepancy in transfer spec
68
79
  transfer_spec['resume_policy'] = POLICY_FIX[transfer_spec['resume_policy']] if transfer_spec.key?('resume_policy')
69
80
  end
81
+
82
+ # Options of `Schema::Documentation` for the transfer spec: supported agents and `ascp` options
83
+ # @param agent [Symbol, nil] `nil`: all parameters, name of agent: parameters it supports, `DOC_OVERVIEW`: one column per agent
84
+ # @return [Hash] Keyword arguments of `Schema::Documentation.new`
85
+ def doc_options(agent = nil)
86
+ return {annotate: ->(node, formatter) { doc_lines(node, formatter, agent: agent) }} unless agent.eql?(DOC_OVERVIEW)
87
+ {
88
+ annotate: ->(node, formatter) { doc_lines(node, formatter, agents_line: false) },
89
+ extra_columns: Agent::Factory::ALL.sort_by { |_, names| names[:short] }.to_h do |sym, names|
90
+ [names[:short].to_s, ->(node, formatter) { formatter.tick(CommandLineBuilder.supported_by_agent(sym, node)) }]
91
+ end
92
+ }
93
+ end
94
+
95
+ private
96
+
97
+ # Documentation of supported agents and command line option of a parameter: `annotate` of `Schema::Documentation`
98
+ # @param node [Hash] Schema of the parameter
99
+ # @param formatter [FormatterInterface] Formatter of the documentation
100
+ # @param agent [Symbol, nil] Show only parameters supported by this agent, or all if `nil`
101
+ # @param agents_line [Boolean] `true`: add a line with supported agents, if not all
102
+ # @return [Array<String>, nil] Lines added to the description, `nil` to hide the parameter
103
+ def doc_lines(node, formatter, agent: nil, agents_line: true)
104
+ agents = Agent::Factory::ALL.keys.select { |sym| CommandLineBuilder.supported_by_agent(sym, node) }
105
+ return if agents.empty? || (agent && !agents.include?(agent))
106
+ lines = []
107
+ lines.push("(#{agents.map { |i| Agent::Factory::ALL[i][:short].to_s.upcase }.sort.join(', ')})") if agents_line && !agents.length.eql?(Agent::Factory::ALL.length)
108
+ lines.concat(CommandLineBuilder.doc_lines(node, formatter))
109
+ end
70
110
  end
71
111
  SCHEMA = CommandLineBuilder.read_schema(Schema::Registry::TRANSFER_SPEC, ascp: true)
72
112
  # Define constants for enums of parameters: <parameter>_<enum>, e.g. CIPHER_AES_128, DIRECTION_SEND, ...
@@ -757,11 +757,14 @@ properties:
757
757
  x-cli-option: -l
758
758
  target_rate:
759
759
  description: >-
760
- Pseudo-parameter: desired transfer rate as a human-readable string with an optional unit suffix.
761
- Converted to `target_rate_kbps` before the transfer is started, overriding it if both are present.
762
- Accepted formats: plain integer (kbps), or integer followed by `k`/`K` (kbps), `m`/`M` (x1000 kbps), `g`/`G` (x1000000 kbps).
763
- Examples: `100000`, `100000k`, `100m`, `1g`.
764
- type: string
760
+ Pseudo-parameter: desired transfer rate in bps, with an optional unit suffix.
761
+ Converted to `target_rate_kbps` (rounded down) before the transfer is started, overriding it if both are present.
762
+ Also applies to `async` (sync).
763
+ Accepted formats: integer (bps), or integer followed by `k`/`K` (x1000), `m`/`M` (x1000000), `g`/`G` (x1000000000).
764
+ Examples: `100000000`, `100000k`, `100m`, `1g`.
765
+ type:
766
+ - string
767
+ - integer
765
768
  x-cli-special: true
766
769
  title:
767
770
  description: Title of the transfer.
@@ -0,0 +1,16 @@
1
+ <%# Content of the page displayed after authentication, in layout of Aspera::WebPage %>
2
+ <div class="icon-circle ok">
3
+ <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">
4
+ <polyline points="6 16 13 23 26 9"/>
5
+ </svg>
6
+ </div>
7
+
8
+ <h1>Authentication Successful</h1>
9
+ <p>You can close this window and return to your terminal.</p>
10
+
11
+ <% unless additional_info.to_s.empty? %>
12
+ <div class="info-box">
13
+ <%= h additional_info %>
14
+ </div>
15
+ <% end %>
16
+