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
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aspera/agent/factory'
4
- require 'aspera/markdown'
5
-
6
3
  module Aspera
7
4
  module Schema
8
- # Generate documentation from Schema, for Transfer Spec, or async Conf spec
5
+ # Generate documentation from a JSON Schema: in terminal and in manual.
6
+ # Information from `x-` extensions is provided by the caller (`annotate`).
9
7
  class Documentation
10
8
  # Map JSON Schema type names to user-friendly display names
11
9
  JSON_TYPE_TO_DOC = {
@@ -16,19 +14,21 @@ module Aspera
16
14
  'array' => 'Array',
17
15
  'object' => 'Hash'
18
16
  }.freeze
19
- # @param formatter [Cli::Formatter] Formatter instance with methods: markdown_text, tick, check_row
20
- # @param schema [Reader]
21
- # @param include_option [Boolean] `true`: include CLI options (switches, env vars) in descriptions
22
- # @param agent_columns [Boolean] `true`: add separate columns for each transfer agent compatibility
17
+ COLUMNS = %w[name type description].freeze
18
+ # @param formatter [FormatterInterface] Formatter with methods: markdown_text, special_format
19
+ # @param schema [Reader]
23
20
  # @param code_highlight [Boolean] `true`: format name and type as code
24
- def initialize(formatter, schema, include_option: false, agent_columns: false, code_highlight: false)
21
+ # @param annotate [Proc, nil] Called with the schema of a property (`Hash`) and the formatter.
22
+ # Returns lines added to the description (`Array<String>`), or `nil` to hide the property.
23
+ # @param extra_columns [Hash{String => Proc}] Columns inserted before the description: name => Proc
24
+ # called with the schema of a property (`Hash`) and the formatter, returns the value of the cell.
25
+ def initialize(formatter, schema, code_highlight: false, annotate: nil, extra_columns: {})
25
26
  @formatter = formatter
26
27
  @schema = schema
27
- @include_option = include_option
28
- @agent_columns = agent_columns
29
28
  @code_highlight = code_highlight
30
- @columns = %w[name type description]
31
- @columns.insert(-2, *Agent::Factory::ALL.values.map { |i| i[:short].to_s }.sort) if @agent_columns
29
+ @annotate = annotate
30
+ @extra_columns = extra_columns
31
+ @columns = COLUMNS.dup.insert(-2, *extra_columns.keys).freeze
32
32
  # Sections: each entry is {header: row_or_nil, rows: []}
33
33
  # A flat schema produces a single section with no header.
34
34
  @sections = [{header: nil, rows: []}]
@@ -50,7 +50,7 @@ module Aspera
50
50
  [@columns] + rows.map { |row| @columns.map { |field| row[field] } }
51
51
  end
52
52
 
53
- # Generate a documentation table from a JSON schema for transfer specifications
53
+ # Generate a documentation table from a JSON schema
54
54
  #
55
55
  # Recursively processes a JSON schema to create a formatted table for manual documentation.
56
56
  # Handles nested objects, arrays, and extracts metadata (descriptions, types, enums, deprecations).
@@ -60,88 +60,60 @@ module Aspera
60
60
  def build(schema = nil)
61
61
  code = @code_highlight ? ->(c) { "`#{c}`" } : ->(c) { c }
62
62
  schema ||= @schema
63
- render_title = ->(title) { title.gsub(Markdown::FORMATS) { @formatter.markdown_text(Regexp.last_match) } }
64
63
  on_variant = ->(variant_reader, discriminant_property, discriminant_value) do
65
64
  title = variant_reader.current['title'] || variant_reader.current['description']
66
65
  header =
67
66
  if discriminant_property && discriminant_value
68
- desc = render_title.call("`#{discriminant_value}`")
69
- desc += ": #{render_title.call(title)}" if title
70
- @formatter.check_row({
71
- 'name' => render_title.call("**#{discriminant_property}**"),
67
+ desc = @formatter.markdown_text("`#{discriminant_value}`")
68
+ desc += ": #{@formatter.markdown_text(title)}" if title
69
+ {
70
+ 'name' => @formatter.markdown_text("**#{discriminant_property}**"),
72
71
  'type' => code.call(JSON_TYPE_TO_DOC['string']),
73
72
  'description' => desc
74
- })
73
+ }
75
74
  elsif title
76
- @formatter.check_row({'name' => "**#{render_title.call(title)}**", 'type' => '&nbsp;', 'description' => '&nbsp;'})
75
+ {'name' => @formatter.markdown_text("**#{title}**")}
77
76
  end
78
77
  @sections.push({header: header, rows: []})
79
78
  end
80
- schema.each_property(on_variant: on_variant) do |property_schema, _name, property_full_name|
79
+ schema.each_property(on_variant: on_variant) do |property_schema, _name, property_full_name, required|
81
80
  node = property_schema.current
82
- # Manual table
83
- item_type =
84
- if node['type'].is_a?(Array)
85
- node['type'].map { |t| JSON_TYPE_TO_DOC.fetch(t, t) }.join(', ')
86
- elsif node['type'].eql?('array') && node.dig('items', 'type').is_a?(String)
87
- "#{JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])}[#{JSON_TYPE_TO_DOC.fetch(node.dig('items', 'type'), node.dig('items', 'type'))}]"
88
- else
89
- JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])
90
- end
91
- item = {
92
- 'name' => code.call(property_full_name),
93
- 'type' => code.call(item_type),
94
- 'description' => []
95
- }
81
+ annotations = @annotate ? @annotate.call(node, @formatter) : []
82
+ next if annotations.nil?
83
+ item = {'name' => code.call(property_full_name)}
84
+ item_type = type_text(node)
85
+ item['type'] = code.call(item_type) unless item_type.nil?
86
+ @extra_columns.each { |column, value| item[column] = value.call(node, @formatter) }
87
+ description = []
88
+ description.push("DEPRECATED: #{node['x-deprecation']}") if node.key?('x-deprecation')
96
89
  # Render Markdown formatting and split lines
97
- item['description'] =
98
- node['description']
99
- .gsub(Markdown::FORMATS) { @formatter.markdown_text(Regexp.last_match) }
100
- .split("\n") if node.key?('description')
101
- item['description'].unshift("DEPRECATED: #{node['x-deprecation']}") if node.key?('x-deprecation')
102
- # Add flags for supported agents in doc
103
- agents = []
104
- Agent::Factory::ALL.each_key do |sym|
105
- agents.push(sym) if node['x-agents'].nil? || node['x-agents'].include?(sym.to_s)
106
- end
107
- Aspera.assert(agents.include?(:direct)) { "#{property_full_name}: x-cli-option requires agent direct (or nil)" } if node['x-cli-option']
108
- if @agent_columns
109
- Agent::Factory::ALL.each do |sym, names|
110
- item[names[:short].to_s] = @formatter.tick(agents.include?(sym))
111
- end
112
- else
113
- item['description'].push("(#{agents.map { |i| Agent::Factory::ALL[i][:short].to_s.upcase }.sort.join(', ')})") unless agents.length.eql?(Agent::Factory::ALL.length)
114
- end
115
- # Only keep lines that are usable in supported agents
116
- next if agents.empty?
117
- item['description'].push("Allowed values: #{node['enum'].map { |v| value_text(v) }.join(', ')}.") if node.key?('enum')
118
- item['description'].push("Default: #{value_text(node['default'])}.") if node.key?('default')
119
- item['description'].push("Example: #{value_text(node['example'])}.") if node.key?('example')
120
- if @include_option
121
- envvar_prefix = ''
122
- cli_option =
123
- if node.key?('x-cli-envvar')
124
- envvar_prefix = 'env:'
125
- node['x-cli-envvar']
126
- elsif node['x-cli-switch']
127
- false_part = node.key?('x-cli-false') ? " / #{node['x-cli-false']}" : ''
128
- "#{node['x-cli-option']}#{false_part}"
129
- elsif node['x-cli-option']
130
- arg_type = node.key?('enum') ? '{enum}' : "{#{[node['type']].flatten.join('|')}}"
131
- conversion_tag = node.key?('x-cli-convert') ? 'conversion' : nil
132
- sep = node['x-cli-option'].start_with?('--') ? '=' : ' '
133
- "#{node['x-cli-option']}#{sep}#{"(#{conversion_tag})" if conversion_tag}#{arg_type}"
134
- end
135
- short = node.key?('x-cli-short') ? "(#{node['x-cli-short']})" : nil
136
- item['description'].push("(#{'special:' if node['x-cli-special']}#{envvar_prefix}#{@formatter.markdown_text("`#{cli_option}`")})#{short}") if cli_option
137
- end
138
- @sections.last[:rows].push(@formatter.check_row(item))
90
+ description.concat(@formatter.markdown_text(node['description']).split("\n")) if node.key?('description')
91
+ description.push('Required.') if required
92
+ description.push("Allowed values: #{node['enum'].map { |v| value_text(v) }.join(', ')}.") if node.key?('enum')
93
+ description.push("Default: #{value_text(node['default'])}.") if node.key?('default')
94
+ description.push("Example: #{value_text(node['example'])}.") if node.key?('example')
95
+ description.concat(annotations)
96
+ item['description'] = description unless description.empty?
97
+ @sections.last[:rows].push(item)
139
98
  end
140
99
  self
141
100
  end
142
101
 
143
102
  private
144
103
 
104
+ # @param node [Hash] Schema of a property
105
+ # @return [String, nil] Readable type, from `type`, or else from the types of `oneOf` alternatives, `nil` if none
106
+ def type_text(node)
107
+ if node['type'].nil?
108
+ types = Array(node['oneOf']).filter_map { |variant| type_text(variant) }.uniq
109
+ return types.empty? ? nil : types.join(', ')
110
+ end
111
+ return node['type'].map { |t| JSON_TYPE_TO_DOC.fetch(t, t) }.join(', ') if node['type'].is_a?(Array)
112
+ result = JSON_TYPE_TO_DOC.fetch(node['type'], node['type'])
113
+ result = "#{result}[#{type_text(node['items'])}]" if node['type'].eql?('array') && node.dig('items', 'type').is_a?(String)
114
+ result
115
+ end
116
+
145
117
  # Format a schema value (enum, default, example) as code
146
118
  # @param value [Object] value from schema, `nil` for JSON `null`
147
119
  # @return [String]
@@ -5,6 +5,10 @@ module Aspera
5
5
  module Schema
6
6
  # JSON schema reader
7
7
  class Reader
8
+ # Keys of a schema node that provide properties
9
+ PROPERTY_SOURCES = %w[properties allOf oneOf].freeze
10
+ private_constant :PROPERTY_SOURCES
11
+
8
12
  attr_reader :current
9
13
 
10
14
  class << self
@@ -72,14 +76,17 @@ module Aspera
72
76
  # Recursively traverse schema properties with a block.
73
77
  # If the current node has `oneOf`, each variant is traversed in turn and
74
78
  # `on_variant` is called (if given) before each variant's properties.
75
- # @param prefix [String] Prefix for property names (e.g., 'parent.child.')
76
- # @param on_variant [Proc, nil] Called with the variant Reader before its properties
77
- # @yield [property_schema, name, full_name] Yields property info to block
79
+ # @param prefix [String] Prefix for property names (e.g., 'parent.child.')
80
+ # @param on_variant [Proc, nil] Called with the variant Reader before its properties
81
+ # @param parent_required [Array<String>] Names required by the enclosing `oneOf` or `allOf` node
82
+ # @yield [property_schema, name, full_name, required] Yields property info to block
78
83
  # @yieldparam property_schema [Reader] Schema reader for this property
79
84
  # @yieldparam name [String] Property name
80
85
  # @yieldparam full_name [String] Full property name with prefix
86
+ # @yieldparam required [Boolean] `true` if listed in `required` of its object, or of the `oneOf` or `allOf` node that includes it
81
87
  # @return [nil]
82
- def each_property(prefix = '', on_variant: nil, &block)
88
+ def each_property(prefix = '', on_variant: nil, parent_required: [], &block)
89
+ required_names = parent_required + Array(@current['required'])
83
90
  if @current.key?('oneOf')
84
91
  # Build reverse map: $ref -> discriminant value, from discriminator.mapping if present
85
92
  discriminant_by_ref = {}
@@ -94,16 +101,24 @@ module Aspera
94
101
  variant_reader = ref ? resolve_ref(ref) : Reader.new(@root, variant_node)
95
102
  discriminant_value = ref ? discriminant_by_ref[ref] : nil
96
103
  on_variant&.call(variant_reader, discriminant_property, discriminant_value)
97
- variant_reader.each_property(prefix, on_variant: on_variant, &block)
104
+ variant_reader.each_property(prefix, on_variant: on_variant, parent_required: required_names, &block)
98
105
  end
99
106
  return
100
107
  end
101
108
  if @current.key?('allOf')
102
- # Merge all branches: each branch contributes its properties (no variants)
103
- @current['allOf'].each do |branch_node|
104
- ref = branch_node['$ref']
105
- branch_reader = ref ? resolve_ref(ref) : Reader.new(@root, branch_node)
106
- branch_reader.each_property(prefix, on_variant: on_variant, &block)
109
+ # Merge all branches: each branch contributes its properties (no variants).
110
+ # A property is listed once, with its first declaration: a later one may be empty,
111
+ # only to allow the property under `additionalProperties: false`.
112
+ # A property is required if any branch requires it.
113
+ branches = @current['allOf'].map { |branch_node| branch_reader(branch_node) }
114
+ required_names += branches.flat_map { |branch| Array(branch.current['required']) }
115
+ listed = Set.new
116
+ branches.each do |branch|
117
+ # A branch may only constrain properties of other branches, e.g. with `required`
118
+ next unless PROPERTY_SOURCES.any? { |key| branch.current.key?(key) }
119
+ branch.each_property(prefix, on_variant: on_variant, parent_required: required_names) do |property_schema, name, full_name, required|
120
+ yield(property_schema, name, full_name, required) if listed.add?(full_name)
121
+ end
107
122
  end
108
123
  return
109
124
  end
@@ -114,7 +129,7 @@ module Aspera
114
129
  node = property_schema.current
115
130
 
116
131
  # Yield current property to block
117
- yield(property_schema, name, property_full_name)
132
+ yield(property_schema, name, property_full_name, required_names.include?(name))
118
133
 
119
134
  # Recursively process nested structures (`type` is a list for a nullable value, e.g. `[array, 'null']`)
120
135
  types = Array(node['type'])
@@ -150,9 +165,22 @@ module Aspera
150
165
 
151
166
  private
152
167
 
168
+ # @param node [Hash] Branch of `allOf` or `oneOf`
169
+ # @return [Reader] Reader on the branch, with `$ref` resolved
170
+ def branch_reader(node)
171
+ node['$ref'] ? resolve_ref(node['$ref']) : Reader.new(@root, node)
172
+ end
173
+
153
174
  # Recursively collect rows from a schema node, passing each property's own
154
175
  # parent `required` array so that only direct-parent membership is checked.
176
+ # Branches of `allOf` are merged: a property is listed once, with its first declaration.
155
177
  def collect_rows(rows, reader, prefix)
178
+ reader.current['allOf']&.each do |branch_node|
179
+ branch_rows = []
180
+ collect_rows(branch_rows, branch_reader(branch_node), prefix)
181
+ listed = rows.to_set { |row| row['name'] }
182
+ rows.concat(branch_rows.reject { |row| listed.include?(row['name']) })
183
+ end
156
184
  return unless reader.current.key?('properties')
157
185
  parent_required = Set.new(Array(reader.current['required']))
158
186
  props_reader = reader.dig('properties')
@@ -62,11 +62,11 @@ module Aspera
62
62
  aoc: 'aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml',
63
63
  automation: 'aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml',
64
64
  faspex: 'aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml',
65
- faspio: 'aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml',
65
+ faspio: 'aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml',
66
66
  console: 'aspera/schema/IBM Aspera Console-enhanced.yaml',
67
67
  node: 'aspera/schema/IBM Aspera Node API-4.4.6.yaml',
68
68
  shares: 'aspera/schema/IBM_Aspera_Shares.yaml',
69
- orchestrator: 'aspera/schema/IBM Aspera Orchestrator API-v1.yaml',
69
+ orchestrator: 'aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml',
70
70
  async_tables: 'aspera/schema/async_tables.yaml'
71
71
  }
72
72
 
@@ -88,6 +88,9 @@ module Aspera
88
88
  SHARES = 'shares+/api/v1'
89
89
  ASYNC_TABLES = 'async_tables'
90
90
  LOG_OPTIONS = "#{OPTIONS}:components.schemas.LogOptions"
91
+ UI_OPTIONS = "#{OPTIONS}:components.schemas.UiOptions"
92
+ SECURITY_OPTIONS = "#{OPTIONS}:components.schemas.SecurityOptions"
93
+ TEMP_OPTIONS = "#{OPTIONS}:components.schemas.TempOptions"
91
94
  DIRECT_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.DirectAgentOptions"
92
95
  NODE_AGENT_OPTIONS = "#{OPTIONS}:components.schemas.NodeAgentOptions"
93
96
  NODE_EMULATOR_OPTIONS = "#{OPTIONS}:components.schemas.NodeEmulatorOptions"
@@ -101,6 +104,9 @@ module Aspera
101
104
  IMAGE_OPTIONS = "#{OPTIONS}:components.schemas.ImageOptions"
102
105
  OUT_OPTIONS = "#{OPTIONS}:components.schemas.OutOptions"
103
106
  PACKAGE_FOLDER_OPTIONS = "#{OPTIONS}:components.schemas.PackageFolderOptions"
107
+ WEB_SERVER_OPTIONS = "#{OPTIONS}:components.schemas.WebServerOptions"
108
+ GATEWAY_OPTIONS = "#{OPTIONS}:components.schemas.Faspex4GatewayOptions"
109
+ POSTPROC_OPTIONS = "#{OPTIONS}:components.schemas.Faspex4PostProcOptions"
104
110
 
105
111
  REQ_BODY = '.requestBody.content.application/json.schema'
106
112
  # Suffix appended to a dotted path to signal query-param extraction in reader()
@@ -4,17 +4,23 @@
4
4
  require 'logger'
5
5
  require 'set'
6
6
  require 'singleton'
7
+ require 'json'
8
+ require 'uri'
7
9
 
8
10
  module Aspera
9
11
  # remove secret from logs and output
10
12
  #
13
+ # Secrets are identified:
14
+ # - by key name, in structures (`secret?`, `hidden_copy`) and in strings with known patterns (`hide_secrets_in_string`)
15
+ # - by value, when declared where they come from (`add_secret_values`, `add_secrets_in`): option values, tokens, ...
16
+ #
11
17
  # @!method self.instance
12
18
  # Returns the singleton instance of SecretHider
13
19
  # @return [SecretHider] the singleton instance
14
20
  class SecretHider
15
21
  include Singleton
16
22
 
17
- # display string for hidden secrets
23
+ # display string for hidden secrets (see `Environment::UNICODE_TO_ASCII`)
18
24
  HIDDEN_PASSWORD = '🔑'
19
25
  # env vars for ascp with secrets
20
26
  ASCP_ENV_SECRETS = %w[ASPERA_SCP_PASS ASPERA_SCP_KEY ASPERA_SCP_FILEPASS ASPERA_PROXY_PASS ASPERA_SCP_TOKEN].freeze
@@ -26,6 +32,9 @@ module Aspera
26
32
  KEY_FALSE_POSITIVES = [/^access_key$/, /^fallback_private_key$/, /public_key$/, /^token_type$/].freeze
27
33
  # min length of hidden secrets, use `+` or `{n,}`
28
34
  SECRET_LENGTH = '{5,}'
35
+ # min length of a declared secret value to be hidden inside a string: shorter ones are hidden only when they are the whole string,
36
+ # else common words would be hidden (e.g. password `aspera` in URLs), or optionally as a word (`short_in_text`)
37
+ VALUE_IN_STRING_LENGTH = 8
29
38
  # regex that define named captures :begin and :end
30
39
  REGEX_LOG_REPLACES = [
31
40
  # private key values (place first)
@@ -38,12 +47,16 @@ module Aspera
38
47
  /(?<begin>(?:(?<quote>["'])|:)[^"':=]*(?:#{ALL_SECRETS.join('|')})[^"':=]*\k<quote>? *(?:=>|:) *")(?:[^"\\]|\\.)+(?<end>")/i,
39
48
  # rendered Ruby 3.4+ symbol key: `k: "v"`
40
49
  /(?<begin>\b\w*(?:#{ALL_SECRETS.join('|')})\w*: *")(?:[^"\\]|\\.)+(?<end>")/i,
50
+ # rendered Ruby Struct or object: `k="v"`, `@k="v"`
51
+ /(?<begin>@?\b\w*(?:#{ALL_SECRETS.join('|')})\w*=")(?:[^"\\]|\\.)+(?<end>")/i,
52
+ # query parameter of URL: `?token=v` or `&client_secret=v`
53
+ /(?<begin>[?&][^=&\s"]*(?:#{KEY_SECRETS.join('|')})[^=&\s"]*=)[^&\s"#]+(?<end>)/i,
41
54
  # logged data
42
55
  /(?<begin>(?:#{NON_ENV_SECRETS.join('|')})[ =:]+)[^ "\n]#{SECRET_LENGTH}(?<end>$)/i,
43
56
  # cred in http dump
44
57
  /(?<begin>(?:#{HTTP_SECRETS.join('|')}): )[^\\]+(?<end>\\)/i
45
58
  ].freeze
46
- private_constant :HIDDEN_PASSWORD, :ASCP_ENV_SECRETS, :KEY_SECRETS, :HTTP_SECRETS, :ALL_SECRETS, :NON_ENV_SECRETS, :KEY_FALSE_POSITIVES, :SECRET_LENGTH, :REGEX_LOG_REPLACES
59
+ private_constant :HIDDEN_PASSWORD, :ASCP_ENV_SECRETS, :KEY_SECRETS, :HTTP_SECRETS, :ALL_SECRETS, :NON_ENV_SECRETS, :KEY_FALSE_POSITIVES, :SECRET_LENGTH, :VALUE_IN_STRING_LENGTH, :REGEX_LOG_REPLACES
47
60
  attr_accessor :log_secrets
48
61
 
49
62
  # @return [Proc] new log formatter that hides secrets
@@ -60,11 +73,13 @@ module Aspera
60
73
 
61
74
  # Replace secrets in a string with the hidden password placeholder
62
75
  # @param value [String] Input string possibly containing secrets
63
- # @param all [Boolean] `false`: only private keys, `true`: all known secret patterns
76
+ # @param all [Boolean] `false`: only declared values and private keys, `true`: also all known secret patterns
64
77
  # @return [String] String with secrets replaced by placeholder
65
78
  def hide_secrets_in_string(value, all: false)
79
+ marker = hidden_marker
80
+ value = hide_values_in_string(value, marker)
66
81
  (all ? REGEX_LOG_REPLACES : REGEX_LOG_REPLACES.first(1)).each do |reg_ex|
67
- value = value.gsub(reg_ex) { "#{Regexp.last_match(:begin)}#{HIDDEN_PASSWORD}#{Regexp.last_match(:end)}" }
82
+ value = value.gsub(reg_ex) { "#{Regexp.last_match(:begin)}#{marker}#{Regexp.last_match(:end)}" }
68
83
  end
69
84
  return value
70
85
  end
@@ -84,21 +99,57 @@ module Aspera
84
99
  ALL_SECRETS.any? { |kw| keyword.include?(kw.downcase) }
85
100
  end
86
101
 
87
- # Hides recursively secrets in Hash or Array of Hash
88
- def deep_remove_secret(obj)
102
+ # Hides secrets in a structure: by key name, and declared values, also in key names (e.g. a secret given as name of a parameter).
103
+ # Containers are copied, not modified: the structure may be shared (e.g. configuration, transfer spec).
104
+ # @param obj [Object] Hash, Array, Struct, String, or other value (returned as is)
105
+ # @param short_in_text [Boolean] `true`: a declared value shorter than 8 characters is also hidden inside a text, as a word
106
+ # (not next to a letter or digit), e.g. a short password in an error message of `mcp server`
107
+ # @return [Object] Copy of obj with secrets replaced by placeholder
108
+ def hidden_copy(obj, short_in_text: false)
89
109
  case obj
90
- when Array
91
- obj.each { |i| deep_remove_secret(i) }
92
110
  when Hash
93
- obj.each do |k, v|
94
- if secret?(k, v)
95
- obj[k] = HIDDEN_PASSWORD
96
- elsif v.is_a?(Hash) || v.is_a?(Array)
97
- deep_remove_secret(v)
98
- end
111
+ obj.to_h do |k, v|
112
+ [k.is_a?(String) ? hidden_copy(k, short_in_text: short_in_text) : k, secret?(k, v) ? hidden_marker : hidden_copy(v, short_in_text: short_in_text)]
99
113
  end
114
+ when Array then obj.map { |i| hidden_copy(i, short_in_text: short_in_text) }
115
+ when Struct then obj.dup.tap { |s| s.each_pair { |k, v| s[k] = secret?(k, v) ? hidden_marker : hidden_copy(v, short_in_text: short_in_text) } }
116
+ when String then hide_values_in_string(obj, hidden_marker, short_in_text: short_in_text)
117
+ else obj
118
+ end
119
+ end
120
+
121
+ # @param obj [Object] Hash, Array, Struct, String, or other value
122
+ # @return [Boolean] `true` if obj contains a secret: by key name, or declared value
123
+ def contains_secret?(obj)
124
+ !hidden_copy(obj).eql?(obj)
125
+ end
126
+
127
+ # Declare secret values: hidden wherever they appear (logs, results), also when escaped in JSON, Ruby or URL
128
+ # @param values [Array<Object>] Secret values: only non-empty Strings are retained
129
+ def add_secret_values(*values)
130
+ forms = values.select { |v| v.is_a?(String) && !v.empty? }.flat_map do |v|
131
+ [v, (JSON.generate(v)[1..-2] rescue nil), v.inspect[1..-2], URI.encode_www_form_component(v)]
132
+ end.compact
133
+ return if forms.empty?
134
+ @mutex.synchronize do
135
+ values = @values | forms
136
+ next if values.size.eql?(@values.size)
137
+ long, short = values.partition { |v| v.length >= VALUE_IN_STRING_LENGTH }
138
+ # Longest first, so that a secret is not partly hidden by another one that it contains
139
+ @values_regex = long.empty? ? nil : Regexp.union(long.sort_by { |v| -v.length })
140
+ @short_values_regex = short.empty? ? nil : /(?<![[:alnum:]])#{Regexp.union(short.sort_by { |v| -v.length })}(?![[:alnum:]])/
141
+ # Replaced, not modified, for readers in other threads
142
+ @values = values.freeze
143
+ end
144
+ end
145
+
146
+ # Declare the secret values found by key name in a structure
147
+ # @param obj [Object] Hash or Array, e.g. a transfer spec
148
+ def add_secrets_in(obj)
149
+ case obj
150
+ when Array then obj.each { |i| add_secrets_in(i) }
151
+ when Hash then obj.each { |k, v| secret?(k, v) ? add_secret_values(v) : add_secrets_in(v) }
100
152
  end
101
- return obj
102
153
  end
103
154
 
104
155
  # Declare additional exact key names whose values are secrets
@@ -112,6 +163,34 @@ module Aspera
112
163
  def initialize
113
164
  @log_secrets = false
114
165
  @additional_keys = Set.new
166
+ # Declared secret values, and their escaped forms
167
+ @values = Set.new.freeze
168
+ # Matches declared values long enough to be hidden inside a string
169
+ @values_regex = nil
170
+ # Matches shorter declared values as a word
171
+ @short_values_regex = nil
172
+ @mutex = Mutex.new
173
+ end
174
+
175
+ # @param value [String] Input string
176
+ # @param marker [String] Placeholder
177
+ # @param short_in_text [Boolean] `true`: also hide short values as a word
178
+ # @return [String] value with declared secret values replaced by placeholder
179
+ def hide_values_in_string(value, marker, short_in_text: false)
180
+ return marker if @values.include?(value)
181
+ long_regex = @values_regex
182
+ short_regex = @short_values_regex if short_in_text
183
+ # Long values first, as they may contain short ones
184
+ value = value.gsub(long_regex, marker) unless long_regex.nil?
185
+ value = value.gsub(short_regex, marker) unless short_regex.nil?
186
+ value
187
+ end
188
+
189
+ # @return [String] Placeholder for hidden secrets, in ASCII when Unicode is not supported
190
+ def hidden_marker
191
+ # Required here, as `aspera/environment` requires `aspera/log`, which requires this file
192
+ require 'aspera/environment'
193
+ Environment.char(HIDDEN_PASSWORD)
115
194
  end
116
195
  end
117
196
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+ require 'aspera/assert'
5
+
6
+ module Aspera
7
+ # Estimate of the strength of a secret (e.g. a token), in bits: log2 of the number of guesses to find it
8
+ #
9
+ # Uses gem `zxcvbn` if installed (dictionaries, l33t, dates...), else a built-in estimate, the lowest of:
10
+ # - alphabet: length x log2(size of character classes used)
11
+ # - Shannon: empirical entropy of characters (catches repeats)
12
+ # - patterns: repeats, sequences and keyboard rows cost 1 bit per character, words and years 2 bits per character
13
+ # Both under-estimate random secrets: a random secret of 32 bytes (e.g. `openssl rand -hex 32`) is far above any usual minimum.
14
+ module SecretStrength
15
+ # Default minimum strength of tokens, in bits: random tokens of 32 bytes are far above
16
+ DEFAULT_MIN_BITS = 80
17
+ # Classes of characters, with their size
18
+ CHARACTER_CLASSES = [[/[a-z]/, 26], [/[A-Z]/, 26], [/\d/, 10], [/[^a-zA-Z\d]/, 33]].freeze
19
+ # Adjacent characters in those rows form a sequence
20
+ KEYBOARD_ROWS = %w[1234567890 qwertyuiop asdfghjkl zxcvbnm azertyuiop qsdfghjklm wxcvbn].freeze
21
+ # Words (3+ letters, optionally capitalized) and years
22
+ LOW_ENTROPY_REGEXES = [/[A-Z]?[a-z]{3,}/, /(?:19|20)\d\d/].freeze
23
+ # Entropy of English text is 1 to 1.5 bit per letter: keep a margin
24
+ LOW_ENTROPY_BITS = 2.0
25
+ # zxcvbn is slow on long secrets: the beginning is enough to assess strength
26
+ ZXCVBN_MAX_LENGTH = 100
27
+ private_constant :CHARACTER_CLASSES, :KEYBOARD_ROWS, :LOW_ENTROPY_REGEXES, :LOW_ENTROPY_BITS, :ZXCVBN_MAX_LENGTH
28
+
29
+ class << self
30
+ # @param secret [String] Secret to assess
31
+ # @return [Float] Estimated strength in bits
32
+ def bits(secret)
33
+ secret = secret.to_s
34
+ return 0.0 if secret.empty?
35
+ zxcvbn_bits(secret) || estimate_bits(secret)
36
+ end
37
+
38
+ # Refuse an empty or weak secret: an empty one would often accept requests without secret
39
+ # @param secret [String] Secret to check
40
+ # @param name [String] Name of the parameter holding the secret, its minimum is in parameter `<name>_min_bits`
41
+ # @param min_bits [Integer] Minimum estimated strength in bits, `0` checks only that the secret is not empty
42
+ # @param type [Exception] Exception to raise
43
+ # @raise [Exception] if the secret is empty or weaker than `min_bits`
44
+ def assert_strong(secret, name:, min_bits: DEFAULT_MIN_BITS, type: ParameterError)
45
+ Aspera.assert(!secret.to_s.empty?, type: type) { "#{name} must not be empty" }
46
+ strength = bits(secret)
47
+ Aspera.assert(strength >= min_bits, type: type) do
48
+ "#{name} is too weak: about #{strength.round} bits, minimum #{min_bits} (#{name}_min_bits). Generate one with: openssl rand -hex 32"
49
+ end
50
+ end
51
+
52
+ # Built-in estimate, without dictionary
53
+ # @param secret [String] Secret to assess
54
+ # @return [Float] Estimated strength in bits
55
+ def estimate_bits(secret)
56
+ return 0.0 if secret.empty?
57
+ bits_per_char = Math.log2(CHARACTER_CLASSES.sum { |regex, size| secret.match?(regex) ? size : 0 })
58
+ [secret.size * bits_per_char, shannon_bits(secret), pattern_bits(secret, bits_per_char)].min
59
+ end
60
+
61
+ private
62
+
63
+ # @return [Float, nil] Strength estimated by gem `zxcvbn`, or `nil` if not installed
64
+ def zxcvbn_bits(secret)
65
+ return unless zxcvbn?
66
+ Math.log2(Zxcvbn.test(secret[0, ZXCVBN_MAX_LENGTH]).guesses)
67
+ rescue StandardError => e
68
+ Log.log.debug { "zxcvbn failed: #{e.message}" }
69
+ nil
70
+ end
71
+
72
+ # @return [Boolean] `true` if gem `zxcvbn` (or `zxcvbn-ruby`) is installed
73
+ def zxcvbn?
74
+ return @zxcvbn unless @zxcvbn.nil?
75
+ @zxcvbn =
76
+ begin
77
+ require 'zxcvbn'
78
+ true
79
+ rescue LoadError
80
+ false
81
+ end
82
+ end
83
+
84
+ # Empirical Shannon entropy of characters of the secret
85
+ def shannon_bits(secret)
86
+ secret.chars.tally.values.sum do |count|
87
+ probability = count.fdiv(secret.size)
88
+ -probability * Math.log2(probability)
89
+ end * secret.size
90
+ end
91
+
92
+ # Sum of bits of each character, depending on patterns
93
+ def pattern_bits(secret, bits_per_char)
94
+ # Bits of characters in words and years
95
+ low = Array.new(secret.size)
96
+ LOW_ENTROPY_REGEXES.each do |regex|
97
+ secret.scan(regex) { low.fill(LOW_ENTROPY_BITS, Regexp.last_match.begin(0)...Regexp.last_match.end(0)) }
98
+ end
99
+ secret.chars.each_with_index.sum do |char, index|
100
+ next 1.0 if index.positive? && sequence?(secret[index - 1], char)
101
+ low[index] || bits_per_char
102
+ end
103
+ end
104
+
105
+ # @return [Boolean] `true` if `char` repeats `previous`, or follows it in alphabet, digits or keyboard row
106
+ def sequence?(previous, char)
107
+ previous = previous.downcase
108
+ char = char.downcase
109
+ return true if (char.ord - previous.ord).abs <= 1
110
+ KEYBOARD_ROWS.any? { |row| (one = row.index(previous)) && (two = row.index(char)) && (one - two).abs.eql?(1) }
111
+ end
112
+ end
113
+ end
114
+ end