localvault 1.7.0 → 1.8.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.
@@ -5,10 +5,106 @@ require "lipgloss"
5
5
  require_relative "env_projection"
6
6
  require_relative "key_lookup"
7
7
  require_relative "session_cache"
8
+ require_relative "stdin_secret_input"
8
9
  require_relative "vault_resolver"
10
+ require_relative "group_catalog"
9
11
 
10
12
  module LocalVault
11
13
  class CLI < Thor
14
+ USAGE_EXIT_STATUS = 1
15
+ GROUP_ALL_SENTINEL = "\0localvault-all-groups"
16
+ GROUP_OFF_SENTINEL = "\0localvault-groups-off"
17
+ CommandStatus = Data.define(:code) do
18
+ def self.ok
19
+ new(0)
20
+ end
21
+
22
+ def self.error
23
+ new(1)
24
+ end
25
+ end
26
+ class GroupSaveError < Thor::Error
27
+ attr_reader :kind, :candidates
28
+
29
+ def initialize(kind, candidates: [])
30
+ @kind = kind
31
+ @candidates = candidates
32
+ super("group save failed")
33
+ end
34
+
35
+ def exit_status
36
+ 1
37
+ end
38
+ end
39
+ class GroupSelectionError < Thor::Error
40
+ attr_reader :kind, :query, :candidates
41
+
42
+ def initialize(kind, query:, candidates: [])
43
+ @kind = kind
44
+ @query = query
45
+ @candidates = candidates
46
+ super("group selection failed")
47
+ end
48
+
49
+ def exit_status
50
+ 1
51
+ end
52
+ end
53
+ class SetValueSourceError < Thor::Error
54
+ attr_reader :kind
55
+
56
+ def initialize(kind, message)
57
+ @kind = kind
58
+ super(message)
59
+ end
60
+
61
+ def exit_status
62
+ 1
63
+ end
64
+ end
65
+
66
+ def self.start(given_args = ARGV, config = {})
67
+ require_relative "cli/error_presenter"
68
+ config[:shell] ||= Thor::Base.shell.new
69
+ result = dispatch(nil, normalize_legacy_group_option(given_args.dup), nil, config)
70
+ result.is_a?(CommandStatus) ? result.code : 0
71
+ rescue Thor::Error => error
72
+ ErrorPresenter.new(self, given_args).render(error)
73
+ error.respond_to?(:exit_status) ? error.exit_status : USAGE_EXIT_STATUS
74
+ rescue Errno::EPIPE
75
+ 0
76
+ end
77
+
78
+ def self.normalize_legacy_group_option(arguments)
79
+ command = arguments.first
80
+ matches = all_commands.keys.select { |name| name.start_with?(command.to_s) }
81
+ return arguments unless command == "show" || matches == ["show"]
82
+
83
+ normalized = []
84
+ index = 0
85
+ while index < arguments.length
86
+ argument = arguments[index]
87
+ if argument == "--"
88
+ normalized.concat(arguments[index..])
89
+ break
90
+ end
91
+ if argument == "--group" && arguments[index + 1]&.match?(/\A(?:true|false|t|f)\z/i)
92
+ enabled = arguments[index + 1].match?(/\A(?:true|t)\z/i)
93
+ normalized << "--group=#{enabled ? GROUP_ALL_SENTINEL : GROUP_OFF_SENTINEL}"
94
+ index += 2
95
+ next
96
+ end
97
+
98
+ case argument
99
+ when /\A--group=(?:true|t)\z/i then normalized << "--group=#{GROUP_ALL_SENTINEL}"
100
+ when /\A--group=(?:false|f)\z/i, "--no-group", "--skip-group" then normalized << "--group=#{GROUP_OFF_SENTINEL}"
101
+ else normalized << argument
102
+ end
103
+ index += 1
104
+ end
105
+ normalized
106
+ end
107
+
12
108
  class_option :vault, aliases: "-v", type: :string, desc: "Vault name"
13
109
 
14
110
  def self.help(shell, subcommand = false)
@@ -22,9 +118,13 @@ module LocalVault
22
118
  shell.say " localvault demo Create a demo vault to explore commands"
23
119
  shell.say ""
24
120
  shell.say "SECRETS"
25
- shell.say " localvault set KEY VALUE Store a secret"
121
+ shell.say " printf '%s' \"$SECRET\" | localvault set KEY --stdin"
122
+ shell.say " Store a secret without argv/history exposure"
123
+ shell.say " localvault set KEY VALUE Store a secret (compatibility path)"
124
+ shell.say " localvault set --group G K V Store a secret inside group G"
26
125
  shell.say " localvault get KEY Retrieve a secret"
27
126
  shell.say " localvault show Display all secrets (masked by default)"
127
+ shell.say " localvault groups [QUERY] List or search stored groups (names only)"
28
128
  shell.say " localvault list List secret key names"
29
129
  shell.say " localvault delete KEY Remove a secret"
30
130
  shell.say " localvault import FILE Bulk-import from .env / .json / .yml"
@@ -71,6 +171,8 @@ module LocalVault
71
171
  shell.say "AI / MCP"
72
172
  shell.say " localvault install-mcp Configure MCP server in your AI tool"
73
173
  shell.say " localvault mcp Start MCP server (stdio)"
174
+ shell.say " localvault mcp --check Check setup and active-vault readiness"
175
+ shell.say " Agents: list names, then use localvault_build_exec for safe injection"
74
176
  shell.say ""
75
177
  shell.say "LEGACY SHARING (pre-v1.2 direct share, still works as fallback)"
76
178
  shell.say " localvault keygen Generate X25519 keypair (same as `keys generate`)"
@@ -82,6 +184,7 @@ module LocalVault
82
184
  shell.say " localvault login --status Show current login status"
83
185
  shell.say " localvault logout Log out"
84
186
  shell.say " localvault version Print version"
187
+ shell.say " localvault doctor Check install and PATH readiness"
85
188
  shell.say " localvault help [COMMAND] Full help for any command"
86
189
  shell.say ""
87
190
  end
@@ -110,10 +213,14 @@ module LocalVault
110
213
  abort_with e.message
111
214
  end
112
215
 
113
- desc "set KEY VALUE", "Store a secret (supports dot-notation for nested keys)"
216
+ desc "set KEY [VALUE]", "Store a secret (supports dot-notation for nested keys)"
114
217
  long_desc <<~DESC
115
218
  Store a secret in the current vault.
116
219
 
220
+ SAFE INPUT (preferred):
221
+ \x05 printf '%s' "$SECRET" | localvault set KEY --stdin
222
+ \x05 printf '%s' "$SECRET" | localvault set --group GROUP KEY --stdin
223
+
117
224
  FLAT KEY (simple):
118
225
  \x05 localvault set DATABASE_URL postgres://localhost/myapp
119
226
  \x05 localvault set STRIPE_KEY sk_live_abc123
@@ -123,14 +230,47 @@ module LocalVault
123
230
  \x05 localvault set platepose.SECRET_KEY_BASE abc123 -v intellectaco
124
231
  \x05 localvault set inventlist.STRIPE_KEY sk_live_abc123 -v intellectaco
125
232
 
233
+ GUIDED GROUP SAVE (same storage, easier to discover):
234
+ \x05 localvault set --group GROUP KEY VALUE
235
+ \x05 localvault set GROUP.KEY VALUE
236
+ \x05 localvault groups [QUERY]
237
+
238
+ Positional values may be visible in process lists and shell history.
126
239
  The dot separates project from key name. One vault can hold many projects.
127
240
  Use `localvault show -p platepose -v vault` to view a single project.
128
241
  Use `localvault import` to bulk-load from a .env, .json, or .yml file.
129
242
  DESC
130
- def set(key, value)
243
+ method_option :group, type: :string, desc: "Store KEY and VALUE inside this named group"
244
+ method_option :stdin, type: :boolean, default: false, desc: "Read VALUE from stdin instead of argv"
245
+ def set(key, value = nil)
246
+ validate_secret_value_source!(value)
131
247
  vault = open_vault!
132
- vault.set(key, value)
133
- $stdout.puts "Set #{key} in vault '#{vault.name}'"
248
+ if options[:group]
249
+ group = canonical_group_name(vault, options[:group])
250
+ validate_group_segment!(group)
251
+ validate_group_segment!(key)
252
+ raise GroupSaveError, :collision if vault.all.key?(group) && !vault.all[group].is_a?(Hash)
253
+ value = read_secret_value(value)
254
+ vault.set("#{group}.#{key}", value)
255
+ $stdout.puts "Set #{key} in group `#{group}` in vault `#{vault.name}`."
256
+ $stdout.puts
257
+ $stdout.puts "Stored as:"
258
+ $stdout.puts " #{group}.#{key}"
259
+ else
260
+ value = read_secret_value(value)
261
+ vault.set(key, value)
262
+ $stdout.puts "Set #{key} in vault '#{vault.name}'"
263
+ end
264
+ rescue StdinSecretInput::InteractiveInput, StdinSecretInput::InvalidEncoding => e
265
+ raise SetValueSourceError.new(:stdin, e.message)
266
+ rescue Vault::InvalidKeyName => e
267
+ raise GroupSaveError, :invalid if options[:group]
268
+ abort_with e.message
269
+ CommandStatus.error
270
+ rescue RuntimeError => e
271
+ raise GroupSaveError, :collision if options[:group]
272
+ abort_with e.message
273
+ CommandStatus.error
134
274
  end
135
275
 
136
276
  desc "get KEY", "Retrieve a secret value by key"
@@ -185,6 +325,30 @@ module LocalVault
185
325
  vault.list.each { |key| $stdout.puts key }
186
326
  end
187
327
 
328
+ desc "groups [QUERY]", "List or search stored secret groups without revealing values"
329
+ long_desc <<~DESC
330
+ Discover dot-notation namespaces and flat-key prefix groups.
331
+
332
+ \x05 localvault groups
333
+ \x05 localvault groups str
334
+ \x05 localvault show --group STRIPE
335
+ \x05 localvault set --group STRIPE API_KEY VALUE
336
+ DESC
337
+ def groups(query = nil)
338
+ vault = open_vault!
339
+ matches = GroupCatalog.new(vault.all).search(query)
340
+ if matches.empty?
341
+ $stdout.puts "No groups match #{query}"
342
+ return
343
+ end
344
+
345
+ heading = query ? "Groups matching `#{query}`" : "Groups"
346
+ $stdout.puts "#{heading} in vault `#{vault.name}`:"
347
+ $stdout.puts
348
+ $stdout.printf(" %-20s %-6s %s\n", "Group", "Keys", "Kind")
349
+ matches.each { |group| $stdout.printf(" %-20s %-6d %s\n", group.name, group.count, group.kind) }
350
+ end
351
+
188
352
  desc "delete KEY", "Remove a secret or entire project group"
189
353
  long_desc <<~DESC
190
354
  Delete a single key or an entire project group.
@@ -350,14 +514,15 @@ module LocalVault
350
514
  \x05 localvault show -p platepose -v intellectaco # one project only
351
515
  \x05 localvault show -p platepose -v intellectaco --reveal
352
516
  DESC
353
- method_option :group, type: :boolean, default: false, desc: "Group flat keys by common prefix"
517
+ method_option :group, type: :string, lazy_default: GROUP_ALL_SENTINEL, desc: "Show all groups or one group by name"
354
518
  method_option :reveal, type: :boolean, default: false, desc: "Show full values instead of masking"
355
519
  method_option :project, aliases: "-p", type: :string, desc: "Show only this project group"
356
520
  def show
357
521
  vault = open_vault!
358
522
  secrets = vault.all
359
523
 
360
- if secrets.empty?
524
+ named_group_query = options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
525
+ if secrets.empty? && !named_group_query
361
526
  $stdout.puts "No secrets in vault '#{vault.name}'."
362
527
  return
363
528
  end
@@ -369,7 +534,17 @@ module LocalVault
369
534
  return
370
535
  end
371
536
  render_table(group.sort.to_h, "#{vault.name}/#{options[:project]}", reveal: options[:reveal])
372
- elsif options[:group] || secrets.values.any? { |v| v.is_a?(Hash) }
537
+ elsif options[:group] && ![GROUP_ALL_SENTINEL, GROUP_OFF_SENTINEL].include?(options[:group])
538
+ match = GroupCatalog.new(secrets).resolve(options[:group])
539
+ if match.group
540
+ entries = match.group.entries.to_h { |entry| [entry.label, entry.value] }
541
+ render_table(entries, "#{vault.name}/#{match.group.name}", reveal: options[:reveal])
542
+ elsif match.kind == :ambiguous
543
+ raise GroupSelectionError.new(:ambiguous, query: options[:group], candidates: match.groups.map(&:name))
544
+ else
545
+ raise GroupSelectionError.new(:absent, query: options[:group])
546
+ end
547
+ elsif options[:group] != GROUP_OFF_SENTINEL && (options[:group] || secrets.values.any? { |v| v.is_a?(Hash) })
373
548
  render_grouped_table(secrets, vault.name, reveal: options[:reveal])
374
549
  else
375
550
  render_table(secrets.sort.to_h, vault.name, reveal: options[:reveal])
@@ -468,8 +643,44 @@ module LocalVault
468
643
  end
469
644
 
470
645
  desc "mcp", "Start MCP server (stdio)"
646
+ long_desc <<~DESC
647
+ Start LocalVault's stdio MCP server for an AI client.
648
+
649
+ Install and verify:
650
+ \x05 localvault install-mcp [claude-code|cursor|windsurf]
651
+ \x05 localvault mcp --check
652
+ \x05 localvault show
653
+
654
+ Do not run `localvault mcp` directly to test it: stdio servers wait for
655
+ JSON-RPC input and therefore appear idle. Use `--check`, then restart the
656
+ configured AI client.
657
+
658
+ Agents should call `list_secrets`, then `localvault_build_exec` to inject
659
+ secrets into a subprocess. Plaintext `get_secret` requires the explicit
660
+ `allow_plaintext: true` acknowledgement.
661
+ DESC
662
+ method_option :check, type: :boolean, default: false, desc: "Check installation and active-vault readiness, then exit"
471
663
  def mcp
472
- require "localvault/mcp/server"
664
+ if options[:check]
665
+ require_relative "mcp/tools"
666
+ status = VaultResolver.readiness_status(options[:vault])
667
+ ready = status["active_vault_unlocked"]
668
+ tool_names = MCP::Tools::DEFINITIONS.map { |definition| definition.fetch("name") }
669
+ $stdout.puts "LocalVault #{VERSION}"
670
+ $stdout.puts "Home: #{Config.root_path}"
671
+ $stdout.puts "MCP readiness: #{ready ? "ready" : "locked"}"
672
+ $stdout.puts "Active vault: #{status["active_vault"]} (#{status["active_vault_source"]})"
673
+ $stdout.puts "Vault session: #{ready ? "available" : "unlock with `localvault show`"}"
674
+ $stdout.puts "MCP tools: #{tool_names.join(", ")}"
675
+ $stdout.puts "Plaintext gate: enabled"
676
+ $stdout.puts "Server instructions: enabled"
677
+ $stdout.puts
678
+ $stdout.puts "Safe agent workflow: list_secrets → localvault_build_exec → run the generated command"
679
+ $stdout.puts "Plaintext retrieval is opt-in with allow_plaintext: true."
680
+ return ready ? CommandStatus.ok : CommandStatus.error
681
+ end
682
+
683
+ require_relative "mcp/server"
473
684
  MCP::Server.new.start
474
685
  end
475
686
 
@@ -1281,8 +1492,48 @@ module LocalVault
1281
1492
  $stdout.puts "localvault #{VERSION}"
1282
1493
  end
1283
1494
 
1495
+ desc "doctor", "Check install and PATH readiness"
1496
+ long_desc <<~DESC
1497
+ Check whether the localvault executable selected by PATH matches the
1498
+ install you expect.
1499
+
1500
+ This catches common Homebrew/asdf shadowing issues after upgrades:
1501
+ \x05 localvault doctor
1502
+ \x05 which -a localvault
1503
+ DESC
1504
+ def doctor
1505
+ paths = localvault_paths
1506
+ warnings = localvault_path_warnings(paths)
1507
+
1508
+ $stdout.puts "LocalVault doctor"
1509
+ $stdout.puts "Version: localvault #{VERSION}"
1510
+ $stdout.puts "Home: #{Config.root_path}"
1511
+
1512
+ if paths.empty?
1513
+ $stdout.puts "Executable selected by PATH: not found"
1514
+ else
1515
+ $stdout.puts "Executable selected by PATH: #{paths.first}"
1516
+ $stdout.puts "All localvault executables on PATH:"
1517
+ paths.each_with_index { |path, index| $stdout.puts " #{index + 1}. #{path}" }
1518
+ end
1519
+
1520
+ if warnings.empty?
1521
+ $stdout.puts "PATH: ok"
1522
+ CommandStatus.ok
1523
+ else
1524
+ $stdout.puts
1525
+ warnings.each { |warning| $stdout.puts "Warning: #{warning}" }
1526
+ $stdout.puts
1527
+ $stdout.puts "Suggested checks:"
1528
+ $stdout.puts " asdf reshim ruby"
1529
+ $stdout.puts " hash -r"
1530
+ $stdout.puts " which -a localvault"
1531
+ CommandStatus.error
1532
+ end
1533
+ end
1534
+
1284
1535
  def self.exit_on_failure?
1285
- true
1536
+ false
1286
1537
  end
1287
1538
 
1288
1539
  no_commands do
@@ -1307,6 +1558,39 @@ module LocalVault
1307
1558
 
1308
1559
  private
1309
1560
 
1561
+ def validate_secret_value_source!(value)
1562
+ if options[:stdin] && !value.nil?
1563
+ raise SetValueSourceError.new(:multiple, "Use either a positional VALUE or --stdin, not both.")
1564
+ end
1565
+
1566
+ return if options[:stdin] || !value.nil?
1567
+
1568
+ raise SetValueSourceError.new(:missing, "Provide a VALUE or read one with --stdin.")
1569
+ end
1570
+
1571
+ def read_secret_value(value)
1572
+ return value unless options[:stdin]
1573
+
1574
+ StdinSecretInput.read($stdin)
1575
+ end
1576
+
1577
+ def canonical_group_name(vault, supplied)
1578
+ groups = GroupCatalog.new(vault.all).groups
1579
+ return supplied if groups.any? { |group| group.name == supplied }
1580
+
1581
+ insensitive = groups.select { |group| group.name.casecmp?(supplied) }
1582
+ return insensitive.first.name if insensitive.one?
1583
+ raise GroupSaveError.new(:ambiguous, candidates: insensitive.map(&:name)) if insensitive.length > 1
1584
+
1585
+ supplied
1586
+ end
1587
+
1588
+ def validate_group_segment!(segment)
1589
+ unless segment.match?(Vault::KEY_SEGMENT_PATTERN) && segment.length <= 128
1590
+ raise GroupSaveError, :invalid
1591
+ end
1592
+ end
1593
+
1310
1594
  # ── Demo data ──────────────────────────────────────────────────
1311
1595
  DEMO_DATA = {
1312
1596
  "default" => {
@@ -1694,22 +1978,57 @@ module LocalVault
1694
1978
  def print_next_steps(client_name)
1695
1979
  $stdout.puts ""
1696
1980
  $stdout.puts "Next steps:"
1697
- $stdout.puts " 1. Restart #{client_name}"
1698
- $stdout.puts " 2. Unlock your vault once: localvault show"
1699
- $stdout.puts " 3. The AI can now access secrets from your default vault"
1981
+ $stdout.puts " 1. Unlock your vault once: localvault show"
1982
+ $stdout.puts " 2. Verify readiness: localvault mcp --check"
1983
+ $stdout.puts " 3. Restart #{client_name}"
1984
+ $stdout.puts " 4. Ask the agent to list names, then use localvault_build_exec"
1985
+ $stdout.puts " for process injection. Plaintext get_secret is explicit opt-in."
1700
1986
  $stdout.puts " Switch vaults: localvault switch <vault>"
1701
1987
  end
1702
1988
 
1703
1989
  no_commands do
1704
1990
  def find_binary(name)
1705
- path = `which #{name} 2>/dev/null`.strip
1706
- path.empty? ? nil : path
1991
+ executable_paths_for(name).first
1707
1992
  end
1708
1993
 
1709
1994
  def system_command_exists?(cmd)
1710
1995
  !find_binary(cmd).nil?
1711
1996
  end
1712
1997
 
1998
+ def localvault_paths
1999
+ executable_paths_for("localvault")
2000
+ end
2001
+
2002
+ def executable_paths_for(name)
2003
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
2004
+ next if directory.empty?
2005
+
2006
+ path = File.join(directory, name)
2007
+ path if File.executable?(path) && !File.directory?(path)
2008
+ end.uniq
2009
+ end
2010
+
2011
+ def localvault_path_warnings(paths)
2012
+ warnings = []
2013
+ if paths.empty?
2014
+ warnings << "localvault is not on PATH. Brew upgrades may be installed but unreachable."
2015
+ return warnings
2016
+ end
2017
+
2018
+ if paths.first.include?("/.asdf/shims/") && paths.any? { |path| homebrew_localvault_path?(path) }
2019
+ warnings << "PATH selects an asdf shim before Homebrew localvault. " \
2020
+ "A stale shim can hide the upgraded brew executable."
2021
+ elsif paths.length > 1
2022
+ warnings << "Multiple localvault executables are on PATH. Confirm the first entry is the one you intend."
2023
+ end
2024
+
2025
+ warnings
2026
+ end
2027
+
2028
+ def homebrew_localvault_path?(path)
2029
+ path.start_with?("/opt/homebrew/bin/", "/usr/local/bin/")
2030
+ end
2031
+
1713
2032
  def cursor_settings_path
1714
2033
  File.expand_path("~/.cursor/mcp.json")
1715
2034
  end
@@ -1,3 +1,5 @@
1
+ require_relative "input_validation"
2
+
1
3
  module LocalVault
2
4
  module EnvProjection
3
5
  class InvalidMapping < StandardError; end
@@ -34,7 +36,10 @@ module LocalVault
34
36
  def self.parse_selectors(value)
35
37
  values = Array(value).compact.flat_map { |v| v.to_s.split(",") }
36
38
  selectors = values.map(&:strip).reject(&:empty?)
39
+ selectors.each { |selector| InputValidation.selector!(selector) }
37
40
  selectors.empty? ? nil : selectors
41
+ rescue InputValidation::InvalidInput => e
42
+ raise InvalidMapping, e.message
38
43
  end
39
44
 
40
45
  def self.parse_map(value)
@@ -43,10 +48,12 @@ module LocalVault
43
48
 
44
49
  key, env_name = pair.split("=", 2).map(&:strip)
45
50
  raise InvalidMapping, "Invalid map '#{pair}'. Use KEY=ENV_NAME" if key.to_s.empty? || env_name.to_s.empty?
46
- raise InvalidMapping, "Invalid environment variable name '#{env_name}'" unless safe_env_name?(env_name)
51
+ InputValidation.mapping!(key, env_name)
47
52
 
48
53
  hash[key] = env_name
49
54
  end
55
+ rescue InputValidation::InvalidInput => e
56
+ raise InvalidMapping, e.message
50
57
  end
51
58
 
52
59
  def self.profile_config(profile)
@@ -0,0 +1,67 @@
1
+ module LocalVault
2
+ class GroupCatalog
3
+ Entry = Data.define(:label, :value, :source_kind)
4
+ Group = Data.define(:name, :kind, :entries) do
5
+ def count
6
+ entries.length
7
+ end
8
+ end
9
+ Match = Data.define(:kind, :query, :groups) do
10
+ def group
11
+ groups.one? ? groups.first : nil
12
+ end
13
+ end
14
+
15
+ attr_reader :groups
16
+
17
+ def initialize(secrets)
18
+ grouped = {}
19
+
20
+ secrets.each do |name, value|
21
+ if value.is_a?(Hash)
22
+ append_namespace(grouped, name, value)
23
+ elsif name.include?("_")
24
+ append_entry(grouped, name.split("_", 2).first, Entry.new(name, value, :prefix))
25
+ end
26
+ end
27
+
28
+ @groups = grouped.map do |name, entries|
29
+ kinds = entries.map(&:source_kind).uniq
30
+ kind = kinds.length > 1 ? :mixed : kinds.first
31
+ Group.new(name, kind, entries.sort_by(&:label).freeze)
32
+ end.sort_by { |group| [group.name.downcase, group.name] }.freeze
33
+ end
34
+
35
+ def search(query = nil)
36
+ return groups if query.nil? || query.empty?
37
+
38
+ needle = query.downcase
39
+ groups.select { |group| group.name.downcase.start_with?(needle) }
40
+ end
41
+
42
+ def resolve(query)
43
+ exact = groups.select { |group| group.name == query }
44
+ return Match.new(:exact, query, exact) if exact.one?
45
+
46
+ insensitive = groups.select { |group| group.name.casecmp?(query) }
47
+ return Match.new(:unique, query, insensitive) if insensitive.one?
48
+ return Match.new(:ambiguous, query, insensitive) if insensitive.length > 1
49
+
50
+ matches = search(query)
51
+ kind = matches.one? ? :unique : (matches.empty? ? :absent : :ambiguous)
52
+ Match.new(kind, query, matches)
53
+ end
54
+
55
+ private
56
+
57
+ def append_namespace(grouped, name, values)
58
+ values.each do |label, value|
59
+ append_entry(grouped, name, Entry.new(label, value, :namespace))
60
+ end
61
+ end
62
+
63
+ def append_entry(grouped, name, entry)
64
+ (grouped[name] ||= []) << entry
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ module LocalVault
2
+ module InputValidation
3
+ class InvalidInput < StandardError; end
4
+
5
+ SEGMENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
6
+ VAULT_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\z/
7
+ PROFILES = %w[aws].freeze
8
+
9
+ module_function
10
+
11
+ def selector!(value)
12
+ string!(value, "selector")
13
+ reject_delimiters!(value, "selector")
14
+ parts = value.split(".", -1)
15
+ valid = parts.length == 1 ? segment?(parts.first) :
16
+ parts.length == 2 && segment?(parts.first) && (segment?(parts.last) || parts.last == "*")
17
+ raise InvalidInput, "selector must be KEY, GROUP.KEY, or GROUP.*" unless valid
18
+
19
+ value
20
+ end
21
+
22
+ def mapping!(source, target)
23
+ selector!(source)
24
+ raise InvalidInput, "mapping source must be an exact key" if source.end_with?(".*")
25
+ string!(target, "mapping target")
26
+ reject_delimiters!(target, "mapping target")
27
+ raise InvalidInput, "mapping target must be an environment variable name" unless segment?(target)
28
+
29
+ [source, target]
30
+ end
31
+
32
+ def project!(value)
33
+ string!(value, "project")
34
+ reject_delimiters!(value, "project")
35
+ raise InvalidInput, "project must be one key segment" unless segment?(value)
36
+
37
+ value
38
+ end
39
+
40
+ def vault_name!(value)
41
+ string!(value, "vault")
42
+ raise InvalidInput, "vault must be a valid vault name" unless value.match?(VAULT_PATTERN)
43
+
44
+ value
45
+ end
46
+
47
+ def argv!(value)
48
+ unless value.is_a?(Array) && !value.empty? &&
49
+ value.all? { |token| token.is_a?(String) && !token.empty? && !token.include?("\0") }
50
+ raise InvalidInput, "command must be a non-empty array of non-empty NUL-free strings"
51
+ end
52
+
53
+ value
54
+ end
55
+
56
+ def profile!(value)
57
+ string!(value, "profile")
58
+ raise InvalidInput, "profile must be one of: #{PROFILES.join(", ")}" unless PROFILES.include?(value)
59
+
60
+ value
61
+ end
62
+
63
+ def segment?(value)
64
+ value.is_a?(String) && value.match?(SEGMENT_PATTERN)
65
+ end
66
+
67
+ def string!(value, label)
68
+ raise InvalidInput, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.empty?
69
+ raise InvalidInput, "#{label} must not contain NUL bytes" if value.include?("\0")
70
+ end
71
+
72
+ def reject_delimiters!(value, label)
73
+ if value.include?(",") || value.include?("=")
74
+ raise InvalidInput, "#{label} must not contain commas or equals signs"
75
+ end
76
+ end
77
+
78
+ private_class_method :string!, :reject_delimiters!
79
+ end
80
+ end