aspera-cli 4.26.2 → 4.27.1

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 (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/environment'
4
+ require 'aspera/keychain/one_password_base'
5
+ require 'json'
6
+
7
+ module Aspera
8
+ module Keychain
9
+ # Manage secrets using the 1Password CLI (`op`)
10
+ # https://developer.1password.com/docs/cli/
11
+ class OnePasswordCli < Base
12
+ include OnePasswordBase
13
+
14
+ OP_EXECUTABLE = 'op'
15
+ private_constant :OP_EXECUTABLE
16
+
17
+ # @param vault [String, nil] Name or ID of the 1Password vault (optional, uses default vault if omitted)
18
+ # @param account [String, nil] Account shorthand for `op` (optional, uses default account if omitted)
19
+ def initialize(vault: nil, account: nil)
20
+ super()
21
+ @vault = vault
22
+ @account = account
23
+ end
24
+
25
+ def info
26
+ h = {}
27
+ h[:vault] = @vault unless @vault.nil?
28
+ h[:account] = @account unless @account.nil?
29
+ h
30
+ end
31
+
32
+ def all
33
+ items = op_json('item', 'list', '--categories', ITEM_CATEGORY)
34
+ return items.map do |item|
35
+ item_to_secret(op_json('item', 'get', item['id'])).merge(id: item['id'])
36
+ end
37
+ end
38
+
39
+ def ids
40
+ op_json('item', 'list', '--categories', ITEM_CATEGORY).map { |item| {id: item['id'], label: item['title']} }
41
+ end
42
+
43
+ def set(options)
44
+ validate_set(options)
45
+ args = ['item', 'create', '--category', ITEM_CATEGORY, "--title=#{options[:label]}"]
46
+ args << "#{FIELD_USERNAME}=#{options[:username]}" if options[:username]
47
+ args << "#{FIELD_PASSWORD}[password]=#{options[:password]}" if options[:password]
48
+ args << "#{FIELD_URL}[url]=#{options[:url]}" if options[:url]
49
+ args << "#{FIELD_NOTES_ID}=#{options[:description]}" if options[:description]
50
+ op_run(*args)
51
+ nil
52
+ end
53
+
54
+ def get(label:, id: nil, exception: true)
55
+ identifier = id || label
56
+ stdout, _stderr, status = op_capture('item', 'get', identifier, '--format=json')
57
+ if !status.success?
58
+ raise "Secret '#{label}' not found" if exception
59
+ return
60
+ end
61
+ full = JSON.parse(stdout)
62
+ return item_to_secret(full).merge(id: full['id'])
63
+ end
64
+
65
+ def delete(label:, id: nil)
66
+ op_run('item', 'delete', id || label)
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def common_flags
73
+ flags = []
74
+ flags.push('--vault', @vault) unless @vault.nil?
75
+ flags.push('--account', @account) unless @account.nil?
76
+ flags
77
+ end
78
+
79
+ def op_json(*args)
80
+ stdout, = op_capture(*args, '--format=json', exception: true)
81
+ JSON.parse(stdout)
82
+ end
83
+
84
+ def op_run(*args)
85
+ op_capture(*args, exception: true)
86
+ nil
87
+ end
88
+
89
+ def op_capture(*args, exception: true)
90
+ Environment.instance.class.secure_execute(
91
+ OP_EXECUTABLE, *args, *common_flags,
92
+ mode: :capture,
93
+ exception: exception
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module Aspera
6
+ # Parse and represent an HTTP Link header as per RFC 8288.
7
+ # Inspired by the link_header gem, with the following fixes:
8
+ # - rel lookup is case-insensitive (RFC 7230: parameter names are case-insensitive)
9
+ # - uses StringScanner so commas inside <URI> are never mistaken for entry separators
10
+ # - no external dependency
11
+ class LinkHeader
12
+ # A single link entry: one URI and its attribute pairs.
13
+ class Link
14
+ attr_reader :href, :attr_pairs
15
+
16
+ def initialize(href, attr_pairs)
17
+ @href = href
18
+ @attr_pairs = attr_pairs
19
+ end
20
+
21
+ # Retrieve an attribute value by name, case-insensitively.
22
+ def [](key)
23
+ pair = @attr_pairs.detect { |k, _v| k.casecmp?(key) }
24
+ pair&.last
25
+ end
26
+ end
27
+
28
+ attr_reader :links
29
+
30
+ def initialize(links = [])
31
+ @links = links
32
+ end
33
+
34
+ # Return the href of the first link whose +rel+ attribute matches +rel+.
35
+ # Comparison is case-insensitive per RFC 7230 s.3.2 and RFC 8288 s.3.
36
+ # Returns nil if no link with that relation exists.
37
+ # @param rel [String]
38
+ # @return [String, nil]
39
+ def find_href(rel: 'next')
40
+ @links.detect { |link| link['rel']&.casecmp?(rel) }&.href
41
+ end
42
+
43
+ class << self
44
+ # Parse a raw Link header value into a +LinkHeader+ instance.
45
+ # Uses StringScanner so that commas inside <URI> are not treated as separators.
46
+ # @param raw [String, nil]
47
+ # @return [LinkHeader]
48
+ def parse(raw)
49
+ return new unless raw && !raw.empty?
50
+
51
+ links = []
52
+ scanner = StringScanner.new(raw)
53
+
54
+ while scanner.scan(HREF_RE)
55
+ href = scanner[1].strip
56
+ attrs = []
57
+ while scanner.scan(ATTR_RE)
58
+ key = scanner[1]
59
+ # scanner[2] = full match (token or "quoted"), scanner[3] = content inside double-quotes
60
+ value = scanner[3] || scanner[2]
61
+ attrs << [key, value]
62
+ break unless scanner.scan(SEMI_RE)
63
+ end
64
+ links << Link.new(href, attrs)
65
+ break unless scanner.scan(COMMA_RE)
66
+ end
67
+
68
+ new(links)
69
+ end
70
+
71
+ private :new
72
+ end
73
+
74
+ # RFC 2616 token: any char except separators
75
+ TOKEN_RE = /[^()<>@,;:\"\[\]?={}\s]+/ # RFC 2616 token
76
+ QUOTED_RE = /"((?:[^"\\]|\\.)*)"/ # double-quoted string with backslash escapes
77
+ HREF_RE = /\s*<([^>]*)>\s*;?\s*/ # <URI> possibly followed by ;
78
+ ATTR_RE = /(#{TOKEN_RE})\s*=\s*(#{TOKEN_RE}|#{QUOTED_RE})\s*/ # key=value or key="value"
79
+ SEMI_RE = /;\s*/ # parameter separator
80
+ COMMA_RE = /,\s*/ # link entry separator
81
+ end
82
+ end
data/lib/aspera/log.rb CHANGED
@@ -20,13 +20,13 @@ class Logger
20
20
 
21
21
  # Add custom level to logger severity, below debug level
22
22
  module Severity
23
- 1.upto(TRACE_MAX).each{ |level| const_set(:"TRACE#{level}", - level)}
23
+ 1.upto(TRACE_MAX).each { |level| const_set(:"TRACE#{level}", - level) }
24
24
  end
25
25
 
26
26
  # Hash
27
27
  # key [Integer] Log level (e.g. 0 for DEBUG)
28
28
  # value [Symbol] Uppercase log level label (e.g. :DEBUG)
29
- SEVERITY_LABEL = Severity.constants.to_h{ |name| [Severity.const_get(name), name]}
29
+ SEVERITY_LABEL = Severity.constants.to_h { |name| [Severity.const_get(name), name] }
30
30
 
31
31
  # Override
32
32
  # @param severity [Integer] Log severity as int
@@ -36,17 +36,20 @@ class Logger
36
36
  end
37
37
 
38
38
  class << self
39
- # Define methods for a given log level
39
+ # Define logger methods for a given log level (e.g. `debug`, `debug?`, `debug!`)
40
+ # @param str_level [String] Log level name (e.g. "DEBUG", "TRACE1")
41
+ # @return [nil]
40
42
  def make_methods(str_level)
41
43
  int_level = ::Logger.const_get(str_level.upcase)
42
44
  method_base = str_level.downcase
43
- define_method(method_base, ->(message = nil, &block){add(int_level, message, &block)})
44
- define_method("#{method_base}?", ->{level <= int_level})
45
- define_method("#{method_base}!", ->{self.level = int_level})
45
+ define_method(method_base, ->(message = nil, &block) { add(int_level, message, &block) })
46
+ define_method("#{method_base}?", -> { level <= int_level })
47
+ define_method("#{method_base}!", -> { self.level = int_level })
48
+ nil
46
49
  end
47
50
  end
48
51
  # Declare methods for all levels
49
- Logger::Severity.constants.each{ |severity| make_methods(severity)}
52
+ Logger::Severity.constants.each { |severity| make_methods(severity) }
50
53
  end
51
54
 
52
55
  # Restore warnings
@@ -65,7 +68,7 @@ module Aspera
65
68
  LOG_TYPES = %i[stderr stdout syslog].freeze
66
69
 
67
70
  # Levels are :trace2,:trace1,:debug,:info,:warn,:error,fatal,:unknown
68
- LEVELS = Logger::Severity.constants.sort{ |a, b| Logger::Severity.const_get(a) <=> Logger::Severity.const_get(b)}.map{ |c| c.downcase.to_sym}.freeze
71
+ LEVELS = Logger::Severity.constants.sort { |a, b| Logger::Severity.const_get(a) <=> Logger::Severity.const_get(b) }.map { |c| c.downcase.to_sym }.freeze
69
72
 
70
73
  # Class methods
71
74
  class << self
@@ -97,7 +100,7 @@ module Aspera
97
100
  JSON.pretty_generate(object) rescue PP.pp(object, +'')
98
101
  when :ruby
99
102
  PP.pp(object, +'')
100
- else Aspera.error_unexpected_value(instance.dump_format){'dump format'}
103
+ else Aspera.error_unexpected_value(instance.dump_format) { 'dump format' }
101
104
  end
102
105
  "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
103
106
  end
@@ -116,7 +119,7 @@ module Aspera
116
119
  # Returns the last 2 containers (module/class) and method caller
117
120
  def caller_method
118
121
  stack = caller
119
- i = stack.rindex{ |line| line.include?('Logger')}
122
+ i = stack.rindex { |line| line.include?('Logger') }
120
123
  frame = stack[i + 1] if i && stack[i + 1]
121
124
  return '???' unless frame
122
125
  # Extract the "Class::Module::Method" or "Class#method" part
@@ -142,9 +145,13 @@ module Aspera
142
145
  attr_reader :logger_type, :logger
143
146
  attr_accessor :dump_format
144
147
 
148
+ # Set the program name used in log output
149
+ # @param value [String] Program name
150
+ # @return [nil]
145
151
  def program_name=(value)
146
152
  @program_name = value
147
153
  self.logger_type = @logger_type
154
+ nil
148
155
  end
149
156
 
150
157
  # Set log level of underlying logger given symbol level
@@ -154,15 +161,19 @@ module Aspera
154
161
  @logger.level = Logger::Severity.const_get(new_level.to_sym.upcase)
155
162
  end
156
163
 
164
+ # Set log formatter; accepts a symbol name, a Proc, or a Logger::Formatter instance
165
+ # @param formatter [String, Proc, Logger::Formatter] Formatter to use; one of: standard, default, caller
166
+ # @return [nil]
157
167
  def formatter=(formatter)
158
168
  if formatter.is_a?(String)
159
- raise Error, "Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}" unless FORMATTER_LAMBDAS.key?(formatter.to_sym)
169
+ Aspera.assert(FORMATTER_LAMBDAS.key?(formatter.to_sym), type: Error) { "Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}" }
160
170
  formatter = FORMATTER_LAMBDAS[formatter.to_sym]
161
171
  elsif !formatter.respond_to?(:call) && !formatter.is_a?(Logger::Formatter)
162
172
  raise Error, 'Formatter must be a String, a Logger::Formatter or a Proc'
163
173
  end
164
174
  # Update formatter with password hiding
165
175
  @logger.formatter = SecretHider.instance.log_formatter(formatter)
176
+ nil
166
177
  end
167
178
 
168
179
  def formatter
@@ -176,7 +187,9 @@ module Aspera
176
187
  Logger::SEVERITY_LABEL[@logger.level].downcase
177
188
  end
178
189
 
179
- # Change underlying logger, but keep log level (default: INFO)
190
+ # Change underlying logger output destination, keeping current log level
191
+ # @param new_log_type [Symbol] Log destination; one of LOG_TYPES (:stderr, :stdout, :syslog)
192
+ # @return [nil]
180
193
  def logger_type=(new_log_type)
181
194
  # [Integer]
182
195
  current_severity_integer = @logger&.level || ENV['AS_LOG_LEVEL']&.to_i || Logger::Severity::INFO
@@ -197,12 +210,13 @@ module Aspera
197
210
  end
198
211
  # Use `local2` facility, like other Aspera components
199
212
  @logger = Syslog::Logger.new(@program_name, Syslog::LOG_LOCAL2)
200
- else Aspera.error_unexpected_value(new_log_type){"log type (#{LOG_TYPES.join(', ')})"}
213
+ else Aspera.error_unexpected_value(new_log_type) { "log type (#{LOG_TYPES.join(', ')})" }
201
214
  end
202
215
  @logger.level = current_severity_integer
203
216
  @logger_type = new_log_type
204
217
  # add secret hider to default logger
205
218
  self.formatter = @logger.formatter
219
+ nil
206
220
  end
207
221
 
208
222
  private
@@ -233,13 +247,13 @@ module Aspera
233
247
  [k, short_levl(k).apply(*v)]
234
248
  end.freeze
235
249
 
236
- DEFAULT_FORMATTER = ->(s, _d, _p, m){"#{LVL_COLOR[s]} #{m}\n"}
250
+ DEFAULT_FORMATTER = ->(s, _d, _p, m) { "#{LVL_COLOR[s]} #{m}\n" }
237
251
 
238
252
  # pre-defined formatters
239
253
  FORMATTER_LAMBDAS = {
240
254
  standard: Logger::Formatter.new,
241
255
  default: DEFAULT_FORMATTER,
242
- caller: ->(s, _d, _p, m){"#{LVL_COLOR[s]} #{Log.caller_method}\n#{m}\n"}
256
+ caller: ->(s, _d, _p, m) { "#{LVL_COLOR[s]} #{Log.caller_method}\n#{m}\n" }
243
257
  }.freeze
244
258
 
245
259
  FORMATTERS = FORMATTER_LAMBDAS.keys
@@ -9,45 +9,128 @@ module Aspera
9
9
 
10
10
  class << self
11
11
  COL_WIDTH = 80
12
+
13
+ # Convert a Markdown heading text to a GitHub-flavoured anchor.
14
+ # Rules: downcase, keep letters/digits/spaces/hyphens, replace spaces with hyphens.
15
+ # Duplicate anchors are disambiguated by appending -1, -2, … (pass a seen Hash to track).
16
+ # @param text [String] raw heading text (without leading # and spaces)
17
+ # @param seen [Hash{String=>Integer}, nil] mutable counter; pass the same Hash across a document
18
+ # @return [String] anchor slug (without leading #)
19
+ def heading_to_anchor(text, seen: nil)
20
+ slug = text
21
+ .downcase
22
+ .gsub(/[`*_]/, '') # strip inline code/bold/italic markers
23
+ .gsub(/&[a-z]+;/, '') # strip HTML entities
24
+ .gsub(/[^\w\s-]/, '') # keep word chars, spaces, hyphens
25
+ .gsub(/\s+/, '-') # spaces → hyphens
26
+ .squeeze('-') # collapse consecutive hyphens
27
+ .strip
28
+ if seen
29
+ count = seen[slug].to_i
30
+ seen[slug] = count + 1
31
+ slug = "#{slug}-#{count}" if count > 0
32
+ end
33
+ slug
34
+ end
35
+
36
+ # Extract the table of contents from a Markdown document.
37
+ # @param content [String] full Markdown source
38
+ # @return [Array<Hash>] array of { level, title, anchor }
39
+ HEADING_RE = /^(\#{1,6})\s+(.+)$/
40
+
41
+ def toc(content)
42
+ seen = {}
43
+ content.each_line.filter_map do |line|
44
+ m = line.match(HEADING_RE)
45
+ next unless m
46
+ title = m[2].strip
47
+ {level: m[1].length, title: title, anchor: heading_to_anchor(title, seen: seen)}
48
+ end
49
+ end
50
+
51
+ # Extract the content of a single section (heading + body until next heading of same/higher level).
52
+ # @param content [String] full Markdown source
53
+ # @param anchor [String] GitHub anchor slug (without #)
54
+ # @return [String, nil] the section content, or nil if not found
55
+ def extract_section(content, anchor)
56
+ seen = {}
57
+ section_level = nil
58
+ result = []
59
+ content.each_line do |line|
60
+ m = line.match(HEADING_RE)
61
+ if m
62
+ slug = heading_to_anchor(m[2].strip, seen: seen)
63
+ if section_level.nil?
64
+ # not yet found: check if this heading matches
65
+ next unless slug == anchor
66
+ section_level = m[1].length
67
+ elsif m[1].length <= section_level
68
+ # already in section: stop at same/higher level heading
69
+ break
70
+ end
71
+ end
72
+ result << line if section_level
73
+ end
74
+ result.empty? ? nil : result.join
75
+ end
76
+
12
77
  # Generate markdown from the provided 2D table
13
78
  # @param table [Array<Array<String>>] 2D array of strings
14
79
  # @return [String] markdown table
15
80
  def table(table)
16
81
  # get max width of each columns
17
82
  col_widths = table.transpose.map do |col|
18
- [col.flat_map{ |c| c.to_s.delete('`').split(HTML_BREAK).map(&:size)}.max, COL_WIDTH].min
83
+ [col.flat_map { |c| c.to_s.delete('`').split(HTML_BREAK).map(&:size) }.max, COL_WIDTH].min
19
84
  end
20
85
  headings = table.shift
21
- table.unshift(col_widths.map{ |col_width| '-' * col_width})
86
+ table.unshift(col_widths.map { |col_width| '-' * col_width })
22
87
  table.unshift(headings)
23
- lines = table.map{ |line| "| #{line.map{ |i| i.to_s.gsub('\\', '\\\\').gsub('|', '\|')}.join(' | ')} |\n"}
88
+ lines = table.map { |line| "| #{line.map { |i| i.to_s.gsub('\\', '\\\\').gsub('|', '\|') }.join(' | ')} |\n" }
24
89
  lines[1] = lines[1].tr(' ', '-')
25
90
  return lines.join.chomp
26
91
  end
27
92
 
28
93
  # Generate markdown list from the provided list
94
+ # @param items [Array<String>] list of items
95
+ # @return [String] markdown unordered list
29
96
  def list(items)
30
- items.map{ |i| "- #{i}"}.join("\n")
97
+ items.map { |i| "- #{i}" }.join("\n")
31
98
  end
32
99
 
100
+ # Generate a markdown heading
101
+ # @param title [String] heading text
102
+ # @param level [Integer] heading level (1–6)
103
+ # @return [String] markdown heading
33
104
  def heading(title, level: 1)
34
105
  "#{'#' * level} #{title}\n\n"
35
106
  end
36
107
 
37
- # type: NOTE CAUTION WARNING IMPORTANT TIP INFO
108
+ # Generate a GitHub-flavoured admonition block
109
+ # @param lines [Array<String>] lines of the admonition body
110
+ # @param type [String] admonition type: NOTE, CAUTION, WARNING, IMPORTANT, TIP, INFO
111
+ # @return [String] markdown admonition block
38
112
  def admonition(lines, type: 'INFO')
39
- "> [!#{type}]\n#{lines.map{ |l| "> #{l}"}.join("\n")}\n\n"
113
+ "> [!#{type}]\n#{lines.map { |l| "> #{l}" }.join("\n")}\n\n"
40
114
  end
41
115
 
116
+ # Generate a fenced code block
117
+ # @param lines [Array<String>] lines of code
118
+ # @param type [String] language identifier for syntax highlighting
119
+ # @return [String] markdown fenced code block
42
120
  def code(lines, type: 'shell')
43
121
  "```#{type}\n#{lines.join("\n")}\n```\n\n"
44
122
  end
45
123
 
46
- # inline code
124
+ # Wrap text in inline code backticks
125
+ # @param text [String] text to wrap
126
+ # @return [String] inline code span
47
127
  def icode(text)
48
128
  "`#{text}`"
49
129
  end
50
130
 
131
+ # Wrap text in a markdown paragraph (trailing blank line)
132
+ # @param text [String] paragraph content
133
+ # @return [String] paragraph with trailing newlines
51
134
  def paragraph(text)
52
135
  "#{text}\n\n"
53
136
  end
data/lib/aspera/nagios.rb CHANGED
@@ -17,7 +17,7 @@ module Aspera
17
17
  # add methods to add nagios error levels, each take component name and message
18
18
  LEVELS.each_index do |code|
19
19
  name = "#{ADD_PREFIX}#{LEVELS[code]}".to_sym
20
- define_method(name){ |comp, msg| @data.push({code: code, comp: comp, msg: msg})}
20
+ define_method(name) { |comp, msg| @data.push({code: code, comp: comp, msg: msg}) }
21
21
  end
22
22
 
23
23
  class << self
@@ -26,18 +26,18 @@ module Aspera
26
26
  Aspera.assert_type(data, Array)
27
27
  Aspera.assert(!data.empty?, 'data is empty')
28
28
  %w[status component message].each do |c|
29
- Aspera.assert(data.first.key?(c)){"result must have #{c}"}
29
+ Aspera.assert(data.first.key?(c)) { "result must have #{c}" }
30
30
  end
31
- res_errors = data.reject{ |s| s['status'].eql?('ok')}
31
+ res_errors = data.reject { |s| s['status'].eql?('ok') }
32
32
  # keep only errors in case of problem, other ok are assumed so
33
33
  data = res_errors unless res_errors.empty?
34
34
  # first is most critical
35
- data.sort!{ |a, b| LEVELS.index(a['status'].to_sym) <=> LEVELS.index(b['status'].to_sym)}
35
+ data.sort! { |a, b| LEVELS.index(a['status'].to_sym) <=> LEVELS.index(b['status'].to_sym) }
36
36
  # build message: if multiple components: concatenate
37
37
  # message = data.map{|i|"#{i['component']}:#{i['message']}"}.join(', ').gsub("\n",' ')
38
38
  message = data
39
- .group_by{ |d| d['component']}
40
- .map{ |comp, items| "#{comp}:#{items.map{ |d| d['message']}.join(',')}"}
39
+ .group_by { |d| d['component'] }
40
+ .map { |comp, items| "#{comp}:#{items.map { |d| d['message'] }.join(',')}" }
41
41
  .join(', ')
42
42
  .tr("\n", ' ')
43
43
  status = data.first['status'].upcase
@@ -60,7 +60,7 @@ module Aspera
60
60
  remote_time = Time.parse(remote_date)
61
61
  diff_time = (remote_time - Time.now).abs
62
62
  diff_rounded = diff_time.round(-2)
63
- Log.log.debug{"DATE: #{remote_date} #{remote_time} diff=#{diff_rounded}"}
63
+ Log.log.debug { "DATE: #{remote_date} #{remote_time} diff=#{diff_rounded}" }
64
64
  msg = "offset #{diff_rounded} sec"
65
65
  if diff_time >= DATE_CRIT_OFFSET
66
66
  add_critical(component, msg)
@@ -80,7 +80,7 @@ module Aspera
80
80
  # @return [Array] of Hash
81
81
  def status_list
82
82
  Aspera.assert(!@data.empty?, 'missing result')
83
- @data.map{ |i| {'status' => LEVELS[i[:code]].to_s, 'component' => i[:comp], 'message' => i[:msg]}}
83
+ @data.map { |i| {'status' => LEVELS[i[:code]].to_s, 'component' => i[:comp], 'message' => i[:msg]} }
84
84
  end
85
85
  end
86
86
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'aspera/assert'
3
4
  require 'aspera/ascp/installation'
4
5
  require 'aspera/agent/direct'
5
6
  require 'aspera/log'
@@ -9,7 +10,7 @@ require 'json'
9
10
  module Aspera
10
11
  class NodeSimulator
11
12
  def initialize
12
- @agent = Agent::Direct.new(management_cb: ->(event){process_event(event)})
13
+ @agent = Agent::Direct.new(management_cb: ->(event) { process_event(event) })
13
14
  @sessions = {}
14
15
  end
15
16
 
@@ -18,7 +19,7 @@ module Aspera
18
19
  end
19
20
 
20
21
  def all_sessions
21
- @agent.sessions.map{ |session| session[:job_id]}.uniq.each.map{ |job_id| job_to_transfer(job_id)}
22
+ @agent.sessions.map { |session| session[:job_id] }.uniq.each.map { |job_id| job_to_transfer(job_id) }
22
23
  end
23
24
 
24
25
  # status: ('waiting', 'partially_completed', 'unknown', 'waiting(read error)',] 'running', 'completed', 'failed'
@@ -128,28 +129,29 @@ module Aspera
128
129
 
129
130
  # Process event from management port
130
131
  def process_event(event)
132
+ # Code duplicated on purpose, as implementation goes, blocks will be replaced
131
133
  case event['Type']
132
- when 'NOP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
134
+ when 'NOP' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
133
135
  # rubocop:disable Lint/DuplicateBranch
134
- when 'START' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
135
- when 'QUERY' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
136
- when 'QUERYRSP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
137
- when 'STATS' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
138
- when 'STOP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
139
- when 'ERROR' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
140
- when 'CANCEL' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
141
- when 'DONE' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
142
- when 'RATE' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
143
- when 'FILEERROR' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
144
- when 'SESSION' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
145
- when 'NOTIFICATION' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
146
- when 'INIT' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
147
- when 'VLINK' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
148
- when 'PUT' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
149
- when 'WRITE' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
150
- when 'CLOSE' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
151
- when 'SKIP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
152
- when 'ARGSTOP' then Aspera.Log.debug{"event not managed: #{event['Type']}"}
136
+ when 'START' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
137
+ when 'QUERY' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
138
+ when 'QUERYRSP' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
139
+ when 'STATS' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
140
+ when 'STOP' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
141
+ when 'ERROR' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
142
+ when 'CANCEL' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
143
+ when 'DONE' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
144
+ when 'RATE' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
145
+ when 'FILEERROR' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
146
+ when 'SESSION' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
147
+ when 'NOTIFICATION' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
148
+ when 'INIT' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
149
+ when 'VLINK' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
150
+ when 'PUT' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
151
+ when 'WRITE' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
152
+ when 'CLOSE' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
153
+ when 'SKIP' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
154
+ when 'ARGSTOP' then Aspera.Log.debug { "event not managed: #{event['Type']}" }
153
155
  # rubocop:enable Lint/DuplicateBranch
154
156
  else Aspera.error_unreachable_line
155
157
  end
@@ -162,19 +164,25 @@ module Aspera
162
164
  PATH_TRANSFERS = '/ops/transfers'
163
165
  PATH_ONE_TRANSFER = %r{/ops/transfers/(.+)$}
164
166
  PATH_BROWSE = '/files/browse'
165
- # @param app_api [Api::AoC]
166
- # @param app_context [String]
167
+ # @param credentials [Hash]
168
+ # @param simulator [NodeSimulator]
167
169
  def initialize(server, credentials, simulator)
168
170
  super(server)
169
171
  @credentials = credentials
170
172
  @simulator = simulator
173
+ # Resolve once at startup; default to current working directory
174
+ @browse_root = File.realpath(credentials[:browse_root] || Dir.pwd)
171
175
  end
172
176
 
173
177
  require 'json'
174
178
  require 'time'
175
179
 
176
180
  def folder_to_structure(folder_path)
177
- raise "Path does not exist or is not a directory: #{folder_path}" unless Dir.exist?(folder_path)
181
+ # Resolve and confine to browse_root (prevents path traversal via client-supplied path)
182
+ resolved = File.realpath(folder_path)
183
+ Aspera.assert(resolved.start_with?("#{@browse_root}/") || resolved.eql?(@browse_root)) { 'Browse path traversal attempt detected' }
184
+ Aspera.assert(Dir.exist?(resolved)) { "Path does not exist or is not a directory: #{resolved}" }
185
+ folder_path = resolved
178
186
 
179
187
  # Build self structure
180
188
  folder_stat = File.stat(folder_path)
@@ -240,7 +248,7 @@ module Aspera
240
248
  when PATH_BROWSE
241
249
  req = JSON.parse(request.body)
242
250
  # req['count']
243
- set_json_response(request, response, folder_to_structure(req['path']))
251
+ set_json_response(request, response, folder_to_structure(req['path'] || @browse_root))
244
252
  else
245
253
  set_json_response(request, response, [{error: 'Bad request'}], code: 400)
246
254
  end
@@ -330,7 +338,7 @@ module Aspera
330
338
  response.status = code
331
339
  response['Content-Type'] = Mime::JSON
332
340
  response.body = json.to_json
333
- Log.log.trace1{Log.obj_dump("response for #{request.request_method} #{request.path}", json)}
341
+ Log.log.trace1 { Log.obj_dump("response for #{request.request_method} #{request.path}", json) }
334
342
  end
335
343
  end
336
344
  end