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
@@ -9,13 +9,14 @@ module Aspera
9
9
  class << self
10
10
  # Generate an ID from a list of object IDs
11
11
  # The generated ID is safe as file name
12
+ # In URLs keep only the host name, we assume only one instance on a given host.
12
13
  # @param ids [Array] the object IDs (can be nested, will be flattened, and nils removed)
13
14
  # @return [String] the generated ID
14
15
  def from_list(*ids)
15
16
  safe_char = Environment.instance.safe_filename_character
16
17
  # compact: remove nils
17
18
  id = ids.flatten.compact.map do |i|
18
- i.is_a?(String) && i.start_with?('https://') ? URI.parse(i).host : i.to_s
19
+ i.is_a?(String) && i.start_with?('http://', 'https://') ? URI.parse(i).host : i.to_s
19
20
  end.join(safe_char)
20
21
  # keep dot for extension only (nicer)
21
22
  return Environment.instance.sanitized_filename(id.gsub('.', safe_char)).downcase
@@ -1,27 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # cspell:ignore blankslate jsonrpc
3
+ # cspell:ignore jsonrpc
4
4
 
5
5
  require 'aspera/rest/error_analyzer'
6
+ require 'aspera/rest/call_error'
6
7
  require 'aspera/assert'
7
8
  require 'aspera/json_rpc/version'
8
- require 'blankslate'
9
9
 
10
- Aspera::Rest::ErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message], always: true)
10
+ # Error in non-2XX response. Error in 2XX response is raised by the client.
11
+ Aspera::Rest::ErrorAnalyzer.instance.add_simple_handler(name: 'JSON RPC', path: %w[error message])
11
12
 
12
13
  module Aspera
14
+ # JSON-RPC 2.0 over HTTP
13
15
  module JsonRpc
16
+ class << self
17
+ # Send a JSON-RPC request
18
+ # @param api [Rest::Client] Aspera REST object pointing at the JSON-RPC endpoint
19
+ # @param method [String] JSON-RPC method
20
+ # @param args [Array] Arguments of call: a single Hash is sent as named params
21
+ # @param id [Integer] Request id
22
+ # @return [Object] `result` of response
23
+ # @raise [Rest::CallError] on JSON-RPC error, or invalid response
24
+ def request(api, method, args, id)
25
+ args = args.first if args.size == 1 && args.first.is_a?(Hash)
26
+ data = api.create('', {
27
+ jsonrpc: VERSION,
28
+ method: method,
29
+ params: args,
30
+ id: id
31
+ })
32
+ Aspera.assert_type(data, Hash, type: Rest::CallError) { 'JSON-RPC response' }
33
+ Aspera.assert(data['jsonrpc'] == VERSION, type: Rest::CallError) { "JSON-RPC: bad version in response: #{data['jsonrpc']}" }
34
+ Aspera.assert(data.key?('result') ^ data.key?('error'), type: Rest::CallError) { 'JSON-RPC: response must have either result or error' }
35
+ # id is null if server could not read it from request
36
+ Aspera.assert(data['id'] == id || (data.key?('error') && data['id'].nil?), type: Rest::CallError) { "JSON-RPC: bad id in response: #{data['id']}, expected #{id}" }
37
+ return data['result'] if data.key?('result')
38
+ error = data['error']
39
+ Aspera.assert(error.is_a?(Hash) && error['code'].is_a?(Integer) && error['message'].is_a?(String), type: Rest::CallError) { "JSON-RPC: bad error in response: #{error}" }
40
+ raise Rest::CallError, "#{error['message']} (code: #{error['code']})"
41
+ end
42
+ end
43
+
14
44
  # JSON-RPC 2.0 client over an Aspera::Rest::Client HTTP endpoint.
15
45
  # Methods are dispatched dynamically via method_missing.
46
+ # Based on BasicObject, so that methods of Object are sent as JSON-RPC requests.
16
47
  # Example:
17
48
  # client = JsonRpc::Client.new(Rest::Client.new(base_url: 'http://127.0.0.1:33024'))
18
49
  # client.get_info
19
50
  # client.start_transfer(app_id: '...', transfer_spec: {...})
20
- class Client < BlankSlate
21
- reveal :instance_variable_get
22
- reveal :inspect
23
- reveal :to_s
24
-
51
+ class Client < BasicObject
25
52
  # @param api [Rest::Client] Aspera REST object pointing at the JSON-RPC endpoint
26
53
  # @param namespace [String, nil] optional method prefix, e.g. "myns."
27
54
  def initialize(api, namespace = nil)
@@ -31,32 +58,24 @@ module Aspera
31
58
  @request_id = 0
32
59
  end
33
60
 
34
- def respond_to_missing?(_sym, _include_private = false)
35
- true
36
- end
61
+ # @return [String] Class and URL of JSON-RPC endpoint
62
+ def inspect = "#<#{Client.name} #{@api.base_url}>"
63
+
64
+ # @return [String] Same as {#inspect}
65
+ def to_s = inspect
66
+
67
+ # Ruby checks it before implicit conversions (`to_ary`, `to_str`, ...): those are not JSON-RPC methods
68
+ # @param _sym [Symbol] Method name
69
+ # @param _include_private [Boolean] Also private methods
70
+ # @return [false]
71
+ def respond_to_missing?(_sym, _include_private = false) = false
37
72
 
38
73
  # Dispatch any Ruby method call as a JSON-RPC request
39
- def method_missing(method, *args, &block)
40
- args = args.first if args.size == 1 && args.first.is_a?(Hash)
41
- data = @api.create('', {
42
- jsonrpc: VERSION,
43
- method: "#{@namespace}#{method}",
44
- params: args,
45
- id: @request_id += 1
46
- })
47
- Aspera.assert_type(data, Hash) { 'response' }
48
- Aspera.assert(data['jsonrpc'] == VERSION, 'bad version in response')
49
- Aspera.assert(data.key?('id'), 'missing id in response')
50
- Aspera.assert(!(data.key?('error') && data.key?('result')), 'both error and response')
51
- Aspera.assert(
52
- !data.key?('error') ||
53
- data['error'].is_a?(Hash) &&
54
- data['error']['code'].is_a?(Integer) &&
55
- data['error']['message'].is_a?(String),
56
- 'bad error response'
57
- )
58
- return data['result']
59
- end
74
+ # @param method [Symbol] JSON-RPC method, without namespace
75
+ # @param args [Array] Arguments of call: a single Hash is sent as named params, else positional params
76
+ # @return [Object] `result` of response
77
+ # @raise [Rest::CallError] on JSON-RPC error, or invalid response
78
+ def method_missing(method, *args) = JsonRpc.request(@api, "#{@namespace}#{method}", args, @request_id += 1)
60
79
  end
61
80
  end
62
81
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Aspera
4
4
  module JsonRpc
5
+ # Protocol version: value of `jsonrpc` in request and response
5
6
  VERSION = '2.0'
6
7
  end
7
8
  end
@@ -20,6 +20,11 @@ module Aspera
20
20
  end
21
21
  Aspera.assert(options.key?(:label), 'label is required')
22
22
  end
23
+
24
+ # Labels are unique: `set` does not overwrite an existing secret
25
+ def assert_new_label(label)
26
+ Aspera.assert(get(label: label, exception: false).nil?) { "secret #{label} already exist, delete first" }
27
+ end
23
28
  end
24
29
  end
25
30
  end
@@ -40,7 +40,8 @@ module Aspera
40
40
  if File.exist?(@path)
41
41
  vault_file = File.read(@path)
42
42
  if vault_file.start_with?('---')
43
- vault_info = YAML.parse(vault_file).to_ruby
43
+ vault_info = Yaml.safe_load(vault_file)
44
+ Aspera.assert_type(vault_info, Hash) { "Invalid vault file: #{@path}" }
44
45
  sorted_keys = vault_info.keys.sort
45
46
  Aspera.assert(sorted_keys == FILE_KEYS || sorted_keys == FILE_KEYS_KDF) { "Invalid vault file: #{@path}: #{sorted_keys}" }
46
47
  @cipher_name = vault_info['cipher']
@@ -82,7 +83,7 @@ module Aspera
82
83
  def set(options)
83
84
  validate_set(options)
84
85
  label = options.delete(:label)
85
- Aspera.assert(!@all_secrets.key?(label)) { "secret #{label} already exist, delete first" }
86
+ assert_new_label(label)
86
87
  @all_secrets[label] = options.symbolize_keys
87
88
  save
88
89
  end
@@ -95,12 +96,15 @@ module Aspera
95
96
  end
96
97
 
97
98
  def delete(label:)
99
+ Aspera.assert(@all_secrets.key?(label)) { "Label not found: #{label}" }
98
100
  @all_secrets.delete(label)
99
101
  save
100
102
  end
101
103
 
102
104
  def change_password(password)
103
105
  # Generate new KDF params so a password change also re-salts the vault
106
+ # (and migrates a legacy vault to PBKDF2)
107
+ @legacy_key = false
104
108
  @kdf_params = nil
105
109
  @cipher = cipher(password)
106
110
  save
@@ -15,7 +15,6 @@ module Aspera
15
15
  Aspera.assert_hash_all(info, Symbol, String) { 'vault info shall have only string values' }
16
16
  info = info.symbolize_keys
17
17
  vault_type = info.delete(:type)
18
- Aspera.assert_values(vault_type, LIST.map(&:to_s)) { 'vault.type' }
19
18
  case vault_type
20
19
  when 'file'
21
20
  # `name` (or `file`), default: `vault.bin`
@@ -40,7 +39,6 @@ module Aspera
40
39
  Keychain::HashicorpVault.new(**info)
41
40
  when '1password'
42
41
  source = info.delete(:source) || 'api'
43
- Aspera.assert_values(source, %w[api cli]) { 'vault.source' }
44
42
  case source
45
43
  when 'api'
46
44
  require 'aspera/keychain/one_password_api'
@@ -49,8 +47,9 @@ module Aspera
49
47
  when 'cli'
50
48
  require 'aspera/keychain/one_password_cli'
51
49
  Keychain::OnePasswordCli.new(**info)
50
+ else Aspera.error_unexpected_value(source, type: ParameterError) { 'vault.source' }
52
51
  end
53
- else Aspera.error_unexpected_value(vault_type)
52
+ else Aspera.error_unexpected_value(vault_type, type: ParameterError) { 'vault.type' }
54
53
  end
55
54
  end
56
55
  end
@@ -32,7 +32,8 @@ module Aspera
32
32
  def all
33
33
  metadata_path = STORE_PATH.sub('/data/', '/metadata/')
34
34
  return Vault.logical.list(metadata_path).filter_map do |label|
35
- get(label: label).merge(label: label)
35
+ # KV v2: deleted secrets are still listed in metadata
36
+ get(label: label, exception: false)&.merge(label: label)
36
37
  end
37
38
  end
38
39
 
@@ -41,6 +42,7 @@ module Aspera
41
42
  def set(options)
42
43
  validate_set(options)
43
44
  label = options.fetch(:label)
45
+ assert_new_label(label)
44
46
  data = {
45
47
  username: options[:username],
46
48
  password: options[:password],
@@ -51,12 +53,13 @@ module Aspera
51
53
  end
52
54
 
53
55
  def get(label:, exception: true)
54
- secret = Vault.logical.read(path(label))
55
- if secret.nil?
56
+ # KV v2: a deleted secret may have metadata but no data
57
+ data = Vault.logical.read(path(label))&.data&.[](:data)
58
+ if data.nil?
56
59
  raise "Secret '#{label}' not found" if exception
57
60
  return
58
61
  end
59
- return secret.data[:data]
62
+ return data
60
63
  end
61
64
 
62
65
  def delete(label:)
@@ -18,7 +18,10 @@ module Aspera
18
18
  DOMAINS = %i[user system common dynamic].freeze
19
19
  LIST_OPTIONS = {
20
20
  domain: :c
21
- }
21
+ }.freeze
22
+ KEYCHAIN_OPTIONS = {
23
+ password: :p
24
+ }.freeze
22
25
  ADD_PASS_OPTIONS = {
23
26
  account: :a,
24
27
  creator: :c,
@@ -37,8 +40,17 @@ module Aspera
37
40
  password: :w,
38
41
  getpass: :g
39
42
  }.freeze
43
+ # Exit code of `security` when the item is not found
44
+ EXIT_ITEM_NOT_FOUND = 44
40
45
  class << self
41
- def execute(command, options = nil, supported = nil, last_opt = nil)
46
+ # Execute a `security` CLI command with given options
47
+ # @param command [String] The security sub-command
48
+ # @param options [Hash, nil] Command options
49
+ # @param supported [Hash, nil] Mapping of option keys to CLI flags
50
+ # @param last_opt [String, nil] Trailing argument (e.g. keychain path)
51
+ # @param exception [Boolean] Whether to raise on non-zero exit
52
+ # @return [Array(String, String, Process::Status)] stdout, stderr, status
53
+ def execute(command, options = nil, supported = nil, last_opt = nil, exception: true)
42
54
  url = options&.delete(:url)
43
55
  if !url.nil?
44
56
  uri = URI.parse(url)
@@ -54,43 +66,82 @@ module Aspera
54
66
  Aspera.assert(supported.key?(k)) { "unknown option: #{k}" }
55
67
  next if v.nil?
56
68
  command_args.push("-#{supported[k]}")
57
- command_args.push(v.shellescape) unless v.empty?
69
+ # No shell is involved: value is passed as is
70
+ command_args.push(v.to_s) unless v.to_s.empty?
58
71
  end
59
72
  command_args.push(last_opt) unless last_opt.nil?
60
- Environment.secure_execute(*command_args, mode: :capture).first
73
+ Environment.secure_execute(*command_args, mode: :capture, exception: exception)
61
74
  end
62
75
 
76
+ # Parse output of list command into Keychain objects
77
+ # @param output [String] Raw CLI output
78
+ # @return [Array<Keychain>] List of keychain instances
63
79
  def key_chains(output)
64
80
  output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
65
81
  end
66
82
 
83
+ # Get the macOS default keychain
84
+ # @return [Keychain] Default keychain instance
67
85
  def default
68
- key_chains(execute('default-keychain')).first
86
+ key_chains(execute('default-keychain').first).first
69
87
  end
70
88
 
89
+ # Get the macOS login keychain
90
+ # @return [Keychain] Login keychain instance
71
91
  def login
72
- key_chains(execute('login-keychain')).first
92
+ key_chains(execute('login-keychain').first).first
73
93
  end
74
94
 
95
+ # List keychains in the search list or domain
96
+ # @param options [Hash] Domain options
97
+ # @return [Array<Keychain>] List of keychains
75
98
  def list(options = {})
76
99
  Aspera.assert_values(options[:domain], DOMAINS, type: ParameterError) { 'domain' } unless options[:domain].nil?
77
- key_chains(execute('list-keychains', options, LIST_OPTIONS))
100
+ key_chains(execute('list-keychains', options, LIST_OPTIONS).first)
78
101
  end
79
102
 
103
+ # Find a keychain by its short name (without `.keychain-db` extension)
104
+ # @param name [String] Keychain name
105
+ # @return [Keychain, nil] Matching keychain or nil
80
106
  def by_name(name)
81
107
  list.find { |kc| kc.path.end_with?("/#{name}.keychain-db") }
82
108
  end
109
+
110
+ # Create a new macOS keychain file
111
+ # @param path [String] File path for the new keychain
112
+ # @param password [String] Password for the new keychain
113
+ # @return [Keychain] Created keychain instance
114
+ def create(path, password:)
115
+ execute('create-keychain', {password: password}, KEYCHAIN_OPTIONS, path)
116
+ new(path)
117
+ end
118
+
119
+ # Set the keychain search list
120
+ # @param keychain_list [Array<Keychain>] Ordered list of keychains
121
+ # @return [Array(String, String, Process::Status)] Execution result
122
+ def search_list=(keychain_list)
123
+ Environment.secure_execute(SECURITY_UTILITY, 'list-keychains', '-s', *keychain_list.map(&:path), mode: :capture)
124
+ end
83
125
  end
84
126
  attr_reader :path
85
127
 
128
+ # @param path [String] File path of the keychain
86
129
  def initialize(path)
87
130
  @path = path
88
131
  end
89
132
 
133
+ # Decode a hexadecimal encoded string into UTF-8
134
+ # @param string [String] Hex string
135
+ # @return [String] Decoded string
90
136
  def decode_hex_blob(string)
91
137
  [string].pack('H*').force_encoding('UTF-8')
92
138
  end
93
139
 
140
+ # Perform generic or internet password operation (add, find, delete)
141
+ # @param operation [Symbol] :add, :find, or :delete
142
+ # @param pass_type [Symbol] :generic or :internet
143
+ # @param options [Hash] Operation options (account, service, password, label, etc.)
144
+ # @return [Hash, nil] Parsed attributes for :find, or nil
94
145
  def password(operation, pass_type, options)
95
146
  Aspera.assert_values(operation, %i[add find delete]) { 'operation' }
96
147
  Aspera.assert_values(pass_type, %i[generic internet]) { 'pass_type' }
@@ -98,11 +149,15 @@ module Aspera
98
149
  missing = (operation.eql?(:add) ? %i[account service password] : %i[label]) - options.keys
99
150
  Aspera.assert(missing.empty?) { "missing options: #{missing}" }
100
151
  options[:getpass] = '' if operation.eql?(:find)
101
- output = self.class.execute("#{operation}-#{pass_type}-password", options, ADD_PASS_OPTIONS, @path)
102
- raise output.gsub(/^.*: /, '') if output.start_with?('security: ')
152
+ stdout, stderr, status = self.class.execute("#{operation}-#{pass_type}-password", options, ADD_PASS_OPTIONS, @path, exception: false)
153
+ unless status.success?
154
+ return if operation.eql?(:find) && status.exitstatus.eql?(EXIT_ITEM_NOT_FOUND)
155
+ raise Error, stderr.strip.gsub(/^.*: /, '')
156
+ end
103
157
  return unless operation.eql?(:find)
104
158
  attributes = {}
105
- output.split("\n").each do |line|
159
+ # Attributes are on stdout, password (option -g) is on stderr
160
+ "#{stdout}\n#{stderr}".split("\n").each do |line|
106
161
  case line
107
162
  when /^keychain: "(.+)"/
108
163
  # ignore
@@ -120,10 +175,47 @@ module Aspera
120
175
  end
121
176
  return attributes
122
177
  end
178
+
179
+ # Unlock this keychain with its password
180
+ # @param password [String] Password of the keychain
181
+ # @return [Array(String, String, Process::Status)] Execution result
182
+ def unlock(password:)
183
+ self.class.execute('unlock-keychain', {password: password}, KEYCHAIN_OPTIONS, @path)
184
+ end
185
+
186
+ # Lock this keychain
187
+ # @return [Array(String, String, Process::Status)] Execution result
188
+ def lock
189
+ self.class.execute('lock-keychain', nil, nil, @path)
190
+ end
191
+
192
+ # Delete this keychain file from disk
193
+ # @return [Array(String, String, Process::Status)] Execution result
194
+ def delete
195
+ self.class.execute('delete-keychain', nil, nil, @path)
196
+ end
197
+
198
+ # Configure settings for this keychain (lock timeout, lock on sleep)
199
+ # @param timeout [Integer, nil] Inactivity timeout in seconds
200
+ # @param lock_on_sleep [Boolean] Whether to lock when system sleeps
201
+ # @return [Array(String, String, Process::Status)] Execution result
202
+ def set_settings(timeout: nil, lock_on_sleep: false)
203
+ args = [SECURITY_UTILITY, 'set-keychain-settings']
204
+ args.push('-l') if lock_on_sleep
205
+ args.push('-u', '-t', timeout.to_s) if timeout
206
+ args.push(@path)
207
+ Environment.secure_execute(*args, mode: :capture)
208
+ end
123
209
  end
124
210
  end
125
211
 
212
+ # Vault backend implementation wrapping macOS system keychain
126
213
  class MacosSystem < Base
214
+ # Account is mandatory in keychain: used when no username is provided
215
+ NO_ACCOUNT = 'none'
216
+ private_constant :NO_ACCOUNT
217
+
218
+ # @param name [String, nil] Specific keychain name, or nil for default
127
219
  def initialize(name: nil)
128
220
  super()
129
221
  @keychain_name = name.nil? ? 'default keychain' : name
@@ -131,25 +223,35 @@ module Aspera
131
223
  Aspera.assert(!@keychain.nil?) { "no such keychain #{name}" }
132
224
  end
133
225
 
226
+ # @return [Hash] Vault information metadata
134
227
  def info
135
228
  return {
136
229
  keychain: @keychain_name
137
230
  }
138
231
  end
139
232
 
233
+ # Overview of all secrets (not supported on macOS due to OS restrictions)
234
+ # @raise [Error] Always raised
140
235
  def all
141
236
  # the only way to list is `dump-keychain` which triggers security alert
142
237
  raise Error, 'overview not implemented, use macos keychain app'
143
238
  end
144
239
 
240
+ # Store a secret in the keychain
241
+ # @param options [Hash] Secret attributes (label, password, username, description)
242
+ # @return [Hash, nil] Result
145
243
  def set(options)
146
244
  validate_set(options)
147
245
  @keychain.password(
148
246
  :add, :generic, service: options[:label],
149
- account: options[:username] || 'none', password: options[:password], comment: options[:description]
247
+ account: options[:username] || NO_ACCOUNT, password: options[:password], comment: options[:description]
150
248
  )
151
249
  end
152
250
 
251
+ # Retrieve a secret by label
252
+ # @param label [String] Secret label identifier
253
+ # @param exception [Boolean] Whether to raise if not found
254
+ # @return [Hash, nil] Secret attributes or nil
153
255
  def get(label:, exception: true)
154
256
  info = @keychain.password(:find, :generic, label: label)
155
257
  if info.nil?
@@ -158,13 +260,17 @@ module Aspera
158
260
  end
159
261
  return {
160
262
  label: label,
263
+ username: (info['acct'] unless info['acct'].eql?(NO_ACCOUNT)),
161
264
  password: info['password'],
162
265
  description: info['icmt'] # cspell: disable-line
163
- }
266
+ }.compact
164
267
  end
165
268
 
269
+ # Delete a secret by label
270
+ # @param label [String] Secret label identifier
271
+ # @return [Hash, nil] Result
166
272
  def delete(label:)
167
- raise Error, 'delete not implemented, use macos keychain app'
273
+ @keychain.password(:delete, :generic, label: label)
168
274
  end
169
275
  end
170
276
  end
@@ -42,6 +42,7 @@ module Aspera
42
42
 
43
43
  def set(options)
44
44
  validate_set(options)
45
+ Aspera.assert(resolve_id(options[:label], exception: false).nil?) { "secret #{options[:label]} already exist, delete first" }
45
46
  @api.create("v1/vaults/#{@vault_id}/items", build_item(options))
46
47
  nil
47
48
  end
@@ -60,10 +61,12 @@ module Aspera
60
61
 
61
62
  private
62
63
 
63
- # Resolve an item id from its title; raises if ambiguous or missing
64
+ # Resolve an item id from its title, or else from its id; raises if ambiguous or missing
64
65
  def resolve_id(label, exception: true)
65
66
  items = @api.read("v1/vaults/#{@vault_id}/items", {filter: "title eq \"#{label}\""})
66
67
  raise "Multiple secrets found with label '#{label}': use id to disambiguate" if items.length > 1
68
+ # No title matches: the identifier may be an item id
69
+ items = @api.read("v1/vaults/#{@vault_id}/items").select { |item| item['id'] == label } if items.empty?
67
70
  raise "Secret '#{label}' not found" if items.empty? && exception
68
71
  items.first&.fetch('id')
69
72
  end
@@ -72,14 +75,16 @@ module Aspera
72
75
  fields = []
73
76
  fields << {'id' => FIELD_USERNAME, 'type' => 'STRING', 'value' => options[:username]} if options[:username]
74
77
  fields << {'id' => FIELD_PASSWORD, 'type' => 'CONCEALED', 'value' => options[:password]} if options[:password]
75
- fields << {'id' => FIELD_URL, 'type' => 'URL', 'value' => options[:url]} if options[:url]
76
78
  fields << {'id' => FIELD_NOTES_ID, 'type' => 'STRING', 'value' => options[:description]} if options[:description]
77
- {
79
+ item = {
78
80
  'title' => options[:label],
79
81
  'category' => ITEM_CATEGORY.upcase,
80
82
  'vault' => {'id' => @vault_id},
81
83
  'fields' => fields
82
84
  }
85
+ # Website of the item
86
+ item['urls'] = [{'href' => options[:url], 'primary' => true}] if options[:url]
87
+ item
83
88
  end
84
89
  end
85
90
  end
@@ -18,16 +18,21 @@ module Aspera
18
18
  private
19
19
 
20
20
  # Convert a 1Password item JSON object to a keychain secret Hash.
21
- # Accepts both API Connect items (field keyed by 'id') and CLI items
22
- # (field keyed by 'id' falling back to 'label').
21
+ # Same JSON schema for API Connect and CLI items.
22
+ # Built-in fields are found by `id`, custom fields (generated `id`) by `label`.
23
+ # URL is the website of the item, or a custom field `url` (created by previous versions).
23
24
  def item_to_secret(item)
24
- fields = Array(item['fields']).to_h { |f| [f['id'] || f['label'], f['value']] }
25
- secret = {label: item['title']}
26
- secret[:username] = fields[FIELD_USERNAME] unless fields[FIELD_USERNAME].nil?
27
- secret[:password] = fields[FIELD_PASSWORD] unless fields[FIELD_PASSWORD].nil?
28
- secret[:url] = fields[FIELD_URL] unless fields[FIELD_URL].nil?
29
- secret[:description] = fields[FIELD_NOTES_ID] unless fields[FIELD_NOTES_ID].nil?
30
- return secret
25
+ fields = Array(item['fields'])
26
+ by_id = fields.to_h { |f| [f['id'], f['value']] }
27
+ by_label = fields.to_h { |f| [f['label'], f['value']] }
28
+ urls = Array(item['urls'])
29
+ values = {
30
+ username: by_id[FIELD_USERNAME] || by_label[FIELD_USERNAME],
31
+ password: by_id[FIELD_PASSWORD] || by_label[FIELD_PASSWORD],
32
+ url: (urls.find { |u| u['primary'] } || urls.first)&.[]('href') || by_id[FIELD_URL] || by_label[FIELD_URL],
33
+ description: by_id[FIELD_NOTES_ID] || by_label[FIELD_NOTES_ID]
34
+ }
35
+ return {label: item['title']}.merge(values.compact)
31
36
  end
32
37
  end
33
38
  end
@@ -42,10 +42,11 @@ module Aspera
42
42
 
43
43
  def set(options)
44
44
  validate_set(options)
45
+ assert_new_label(options[:label])
45
46
  args = ['item', 'create', '--category', ITEM_CATEGORY, "--title=#{options[:label]}"]
47
+ args << "--url=#{options[:url]}" if options[:url]
46
48
  args << "#{FIELD_USERNAME}=#{options[:username]}" if options[:username]
47
49
  args << "#{FIELD_PASSWORD}[password]=#{options[:password]}" if options[:password]
48
- args << "#{FIELD_URL}[url]=#{options[:url]}" if options[:url]
49
50
  args << "#{FIELD_NOTES_ID}=#{options[:description]}" if options[:description]
50
51
  op_run(*args)
51
52
  nil
@@ -53,8 +54,9 @@ module Aspera
53
54
 
54
55
  def get(label:, id: nil, exception: true)
55
56
  identifier = id || label
56
- stdout, _stderr, status = op_capture('item', 'get', identifier, '--format=json')
57
+ stdout, stderr, status = op_capture('item', 'get', identifier, '--format=json', exception: false)
57
58
  if !status.success?
59
+ raise "Multiple secrets found with label '#{label}': use id to disambiguate, or option vault to restrict" if stderr.to_s.match?(/more than one item/i)
58
60
  raise "Secret '#{label}' not found" if exception
59
61
  return
60
62
  end
data/lib/aspera/log.rb CHANGED
@@ -110,11 +110,8 @@ module Aspera
110
110
  return unless instance.logger.send(:"#{level}?")
111
111
  Aspera.assert(object.nil? || block.nil?, 'Use either object, or block, not both')
112
112
  object = yield if block_given?
113
- instance.logger.send(level, obj_dump(name, object))
114
- end
115
-
116
- # @return [String] Dump of object
117
- def obj_dump(name, object)
113
+ # Secrets hidden by key name in the structure, whatever the dump format (the log formatter then hides secrets in text)
114
+ object = SecretHider.instance.hidden_copy(object) unless SecretHider.instance.log_secrets
118
115
  dump_text =
119
116
  case instance.dump_format
120
117
  when :json
@@ -123,7 +120,7 @@ module Aspera
123
120
  PP.pp(object, +'')
124
121
  else Aspera.error_unexpected_value(instance.dump_format) { 'dump format' }
125
122
  end
126
- "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
123
+ instance.logger.send(level, "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}")
127
124
  end
128
125
 
129
126
  # Capture the output of $stderr and log it at debug level