localvault 1.14.0 → 1.15.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.
- checksums.yaml +4 -4
- data/README.md +26 -0
- data/lib/localvault/cli/sync.rb +308 -19
- data/lib/localvault/cli/team_helpers.rb +4 -0
- data/lib/localvault/cli.rb +6 -0
- data/lib/localvault/store.rb +28 -1
- data/lib/localvault/sync_merge.rb +204 -0
- data/lib/localvault/sync_state.rb +42 -2
- data/lib/localvault/vault.rb +18 -0
- data/lib/localvault/version.rb +1 -1
- data/lib/localvault.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cfbfdee976a274c577ccb8e782e21f3d64bd1b71ba30e37a5bb583fc1ca54c4
|
|
4
|
+
data.tar.gz: 9dd3b6663bd7e0eb15d2a6e00fd4d04afb6c0de3962ed76f219418fc4c01207a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2849e50bf55078b747403fdc65bde8ae1cabb4c304fd22a422c92f4dd12e2a482ddab6d731b6c18d9cba077e86b93eefdb54dffd2724d28ae087a543efcadbc
|
|
7
|
+
data.tar.gz: 7d42cfe5bc6c237e6d9b4d9aeefef4e223ff965471c6b417fbf15e7841e1d05a74edfed09221b89b300902ed88ec67afe2e565bd03c46729a2cc5f90debb573c
|
data/README.md
CHANGED
|
@@ -125,6 +125,8 @@ localvault exec -- rails server
|
|
|
125
125
|
| `sync --dry-run` | Preview what sync would do without making changes |
|
|
126
126
|
| `sync push [NAME]` | Push one vault to cloud |
|
|
127
127
|
| `sync pull [NAME]` | Pull one vault from cloud (auto-unlocks if you have a key slot) |
|
|
128
|
+
| `sync diff [NAME]` | Show which keys differ between local and cloud (names only, never values) |
|
|
129
|
+
| `sync merge [NAME]` | Three-way merge local and cloud key by key, then push (`--prefer local\|remote`, `--local KEY`, `--remote KEY`) |
|
|
128
130
|
| `sync status` | Show sync state for all vaults |
|
|
129
131
|
|
|
130
132
|
### Team Sharing (v1.3.0)
|
|
@@ -201,6 +203,30 @@ localvault sync --dry-run
|
|
|
201
203
|
# staging pull remote changes
|
|
202
204
|
```
|
|
203
205
|
|
|
206
|
+
### Resolving a conflict
|
|
207
|
+
|
|
208
|
+
When a vault changed on both machines since the last sync, `sync` stops and
|
|
209
|
+
shows which keys differ. Values are never printed.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
localvault sync
|
|
213
|
+
# devops CONFLICT both local and remote changed since last sync
|
|
214
|
+
# cloud added REMOTE_ONLY will take cloud
|
|
215
|
+
# local added LOCAL_ONLY will keep local
|
|
216
|
+
# CONFLICT changed on both sides SHARED needs a choice
|
|
217
|
+
|
|
218
|
+
localvault sync merge devops # clean merge: keeps every change from both sides
|
|
219
|
+
localvault sync merge devops --prefer remote # conflicting keys: take cloud
|
|
220
|
+
localvault sync merge devops --local SHARED # or decide per key (--local / --remote, repeatable)
|
|
221
|
+
localvault sync diff devops # just look, change nothing
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The merge is three-way: each sync records an encrypted snapshot of the
|
|
225
|
+
last-synced state, so a key edited on only one side applies automatically and
|
|
226
|
+
only keys edited differently on both sides need a choice. Deleting a key on one
|
|
227
|
+
side while the other side edits it is also a conflict. `sync push` and
|
|
228
|
+
`sync pull --force` still take one side wholesale.
|
|
229
|
+
|
|
204
230
|
## Team Sharing
|
|
205
231
|
|
|
206
232
|
Share vault access with teammates using X25519 asymmetric encryption. The server never sees plaintext.
|
data/lib/localvault/cli/sync.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
require "thor"
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "digest"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require_relative "team_helpers"
|
|
4
6
|
|
|
5
7
|
module LocalVault
|
|
6
8
|
class CLI
|
|
7
9
|
class Sync < Thor
|
|
10
|
+
include LocalVault::CLI::TeamHelpers
|
|
11
|
+
|
|
8
12
|
desc "all", "Sync all vaults bidirectionally (push local changes, pull remote changes)"
|
|
9
13
|
method_option :dry_run, type: :boolean, default: false, desc: "Show what would happen without making changes"
|
|
10
14
|
# Smart bidirectional sync for all vaults.
|
|
@@ -18,7 +22,7 @@ module LocalVault
|
|
|
18
22
|
# - Both exist, only remote changed → pull
|
|
19
23
|
# - Both exist, neither changed → skip
|
|
20
24
|
# - Both exist, no baseline but secrets identical → adopt (record baseline)
|
|
21
|
-
# - Both exist, both changed → CONFLICT (
|
|
25
|
+
# - Both exist, both changed → CONFLICT (resolve with sync merge / push / pull --force)
|
|
22
26
|
# - Shared vault (not owned by you) → pull-only
|
|
23
27
|
def all
|
|
24
28
|
return unless logged_in?
|
|
@@ -94,14 +98,20 @@ module LocalVault
|
|
|
94
98
|
parts << "#{conflicts} conflict#{conflicts == 1 ? "" : "s"}" if conflicts > 0
|
|
95
99
|
$stdout.puts "Summary: #{parts.join(", ")}"
|
|
96
100
|
|
|
97
|
-
# Conflict guidance
|
|
101
|
+
# Conflict guidance — key names only, never values
|
|
98
102
|
if conflicts > 0
|
|
99
103
|
$stdout.puts
|
|
100
104
|
plan.select { |p| p[:action] == :conflict }.each do |p|
|
|
101
105
|
$stderr.puts " #{p[:name]} — #{p[:reason]}"
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
result = quiet_merge_preview(p[:name], client)
|
|
107
|
+
if result
|
|
108
|
+
print_merge_report(result, indent: " ", io: $stderr)
|
|
109
|
+
print_resolution_help(p[:name], result, io: $stderr)
|
|
110
|
+
else
|
|
111
|
+
$stderr.puts " See which keys differ (no values shown):"
|
|
112
|
+
$stderr.puts " localvault sync diff #{p[:name]}"
|
|
113
|
+
print_resolution_help(p[:name], nil, io: $stderr)
|
|
114
|
+
end
|
|
105
115
|
end
|
|
106
116
|
end
|
|
107
117
|
rescue ApiClient::ApiError => e
|
|
@@ -129,6 +139,98 @@ module LocalVault
|
|
|
129
139
|
perform_pull(vault_name, client, force: options[:force])
|
|
130
140
|
end
|
|
131
141
|
|
|
142
|
+
desc "diff [NAME]", "Show which keys differ between local and cloud (names only, no values)"
|
|
143
|
+
method_option :vault, type: :string, aliases: "-v", desc: "Vault name (same as NAME)"
|
|
144
|
+
def diff(vault_name = nil)
|
|
145
|
+
return unless logged_in?
|
|
146
|
+
vault_name ||= options[:vault] || Config.default_vault
|
|
147
|
+
client = ApiClient.new(token: Config.token)
|
|
148
|
+
|
|
149
|
+
result = build_merge(vault_name, client)
|
|
150
|
+
return false unless result
|
|
151
|
+
|
|
152
|
+
if result.local_changes.empty? && result.remote_changes.empty? &&
|
|
153
|
+
result.same_changes.empty? && result.conflicts.empty?
|
|
154
|
+
$stdout.puts "#{vault_name}: local and cloud hold the same secrets."
|
|
155
|
+
return true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
$stdout.puts "#{vault_name} — three-way diff against last sync (values never shown)"
|
|
159
|
+
print_merge_report(result, indent: " ", io: $stdout)
|
|
160
|
+
$stdout.puts
|
|
161
|
+
print_resolution_help(vault_name, result, io: $stdout)
|
|
162
|
+
true
|
|
163
|
+
rescue ApiClient::ApiError => e
|
|
164
|
+
$stderr.puts "Error: #{e.message}"
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
desc "merge [NAME]", "Three-way merge local and cloud, then push the result"
|
|
169
|
+
method_option :vault, type: :string, aliases: "-v", desc: "Vault name (same as NAME)"
|
|
170
|
+
method_option :prefer, type: :string, enum: %w[local remote], desc: "Resolve every conflicting key from this side"
|
|
171
|
+
method_option :local, type: :array, default: [], desc: "Keys to keep from local (repeatable)"
|
|
172
|
+
method_option :remote, type: :array, default: [], desc: "Keys to take from cloud (repeatable)"
|
|
173
|
+
method_option :dry_run, type: :boolean, default: false, desc: "Show the merge plan without writing or pushing"
|
|
174
|
+
method_option :push, type: :boolean, default: true, desc: "Push after merging (--no-push keeps it local)"
|
|
175
|
+
# Merge cloud changes into the local vault key by key using the
|
|
176
|
+
# ancestor snapshot recorded at the last sync. Non-conflicting changes
|
|
177
|
+
# from both sides apply automatically; a key changed differently on
|
|
178
|
+
# both sides needs +--prefer+ or a per-key +--local+/+--remote+ pick.
|
|
179
|
+
def merge(vault_name = nil)
|
|
180
|
+
return unless logged_in?
|
|
181
|
+
vault_name ||= options[:vault] || Config.default_vault
|
|
182
|
+
client = ApiClient.new(token: Config.token)
|
|
183
|
+
|
|
184
|
+
both = options[:local] & options[:remote]
|
|
185
|
+
unless both.empty?
|
|
186
|
+
$stderr.puts "Error: #{both.join(", ")} given to both --local and --remote. Pick one side per key."
|
|
187
|
+
return false
|
|
188
|
+
end
|
|
189
|
+
picks = {}
|
|
190
|
+
options[:local].each { |k| picks[k] = :local }
|
|
191
|
+
options[:remote].each { |k| picks[k] = :remote }
|
|
192
|
+
prefer = options[:prefer]&.to_sym
|
|
193
|
+
|
|
194
|
+
result = build_merge(vault_name, client, prefer: prefer, picks: picks)
|
|
195
|
+
return false unless result
|
|
196
|
+
|
|
197
|
+
unknown = picks.keys - result.conflict_keys_before_picks
|
|
198
|
+
unless unknown.empty?
|
|
199
|
+
$stderr.puts "Warning: #{unknown.join(", ")} #{unknown.size == 1 ? "is" : "are"} not in conflict — ignored."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
$stdout.puts "#{vault_name} — merge plan (values never shown)"
|
|
203
|
+
print_merge_report(result, indent: " ", io: $stdout)
|
|
204
|
+
|
|
205
|
+
unless result.clean?
|
|
206
|
+
$stdout.puts
|
|
207
|
+
$stderr.puts "Error: #{result.conflicts.size} key#{result.conflicts.size == 1 ? "" : "s"} changed on both sides. Choose a side:"
|
|
208
|
+
print_resolution_help(vault_name, result, io: $stderr, merge_only: true)
|
|
209
|
+
return false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if options[:dry_run]
|
|
213
|
+
$stdout.puts
|
|
214
|
+
$stdout.puts "Dry run — no changes made."
|
|
215
|
+
return true
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
master_key = @merge_master_key
|
|
219
|
+
vault = Vault.new(name: vault_name, master_key: master_key)
|
|
220
|
+
vault.replace(result.merged)
|
|
221
|
+
$stdout.puts " merged #{vault_name} locally"
|
|
222
|
+
|
|
223
|
+
unless options[:push]
|
|
224
|
+
$stdout.puts " not pushed (--no-push). Push later with: localvault sync push #{vault_name}"
|
|
225
|
+
return true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
perform_push(vault_name, client)
|
|
229
|
+
rescue ApiClient::ApiError => e
|
|
230
|
+
$stderr.puts "Error: #{e.message}"
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
|
|
132
234
|
desc "status", "Show sync status for all vaults"
|
|
133
235
|
def status
|
|
134
236
|
return unless logged_in?
|
|
@@ -217,6 +319,8 @@ module LocalVault
|
|
|
217
319
|
end
|
|
218
320
|
|
|
219
321
|
key_slots = bootstrap_owner_slot(key_slots, store)
|
|
322
|
+
key_slots = refresh_scoped_slots(key_slots, store)
|
|
323
|
+
return false unless key_slots
|
|
220
324
|
blob = SyncBundle.pack_v3(store, owner: owner, key_slots: key_slots)
|
|
221
325
|
else
|
|
222
326
|
blob = SyncBundle.pack(store)
|
|
@@ -224,11 +328,8 @@ module LocalVault
|
|
|
224
328
|
|
|
225
329
|
client.push_vault(vault_name, blob)
|
|
226
330
|
|
|
227
|
-
# Record sync state
|
|
228
|
-
SyncState.new(vault_name).
|
|
229
|
-
checksum: SyncState.local_checksum(store),
|
|
230
|
-
direction: "push"
|
|
231
|
-
)
|
|
331
|
+
# Record sync state + ancestor snapshot for future merges
|
|
332
|
+
SyncState.new(vault_name).record!(store, direction: "push")
|
|
232
333
|
|
|
233
334
|
$stdout.puts " pushed #{vault_name} (#{blob.bytesize} bytes)"
|
|
234
335
|
true
|
|
@@ -266,11 +367,8 @@ module LocalVault
|
|
|
266
367
|
store.write_encrypted(data[:secrets])
|
|
267
368
|
end
|
|
268
369
|
|
|
269
|
-
# Record sync state
|
|
270
|
-
SyncState.new(vault_name).
|
|
271
|
-
checksum: SyncState.local_checksum(store),
|
|
272
|
-
direction: "pull"
|
|
273
|
-
)
|
|
370
|
+
# Record sync state + ancestor snapshot for future merges
|
|
371
|
+
SyncState.new(vault_name).record!(store, direction: "pull")
|
|
274
372
|
|
|
275
373
|
$stdout.puts " pulled #{vault_name}"
|
|
276
374
|
|
|
@@ -401,10 +499,7 @@ module LocalVault
|
|
|
401
499
|
# @return [Boolean] true on success, false on any error
|
|
402
500
|
def perform_adopt(vault_name)
|
|
403
501
|
store = Store.new(vault_name)
|
|
404
|
-
SyncState.new(vault_name).
|
|
405
|
-
checksum: SyncState.local_checksum(store),
|
|
406
|
-
direction: "adopt"
|
|
407
|
-
)
|
|
502
|
+
SyncState.new(vault_name).record!(store, direction: "adopt")
|
|
408
503
|
$stdout.puts " baselined #{vault_name} (already in sync)"
|
|
409
504
|
true
|
|
410
505
|
rescue StandardError => e
|
|
@@ -462,6 +557,161 @@ module LocalVault
|
|
|
462
557
|
false
|
|
463
558
|
end
|
|
464
559
|
|
|
560
|
+
# ── Merge helpers ────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
# Decrypt base / local / remote and run the three-way merge. Prompts
|
|
563
|
+
# for the passphrase when the vault isn't unlocked. Returns nil (after
|
|
564
|
+
# printing the reason) when anything needed is missing.
|
|
565
|
+
#
|
|
566
|
+
# @return [SyncMerge::Result, nil]
|
|
567
|
+
def build_merge(vault_name, client, prefer: nil, picks: {})
|
|
568
|
+
store = Store.new(vault_name)
|
|
569
|
+
unless store.exists?
|
|
570
|
+
$stderr.puts "Error: Vault '#{vault_name}' does not exist locally. Use: localvault sync pull #{vault_name}"
|
|
571
|
+
return nil
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
master_key = ensure_master_key(vault_name)
|
|
575
|
+
return nil unless master_key
|
|
576
|
+
@merge_master_key = master_key
|
|
577
|
+
|
|
578
|
+
blob = client.pull_vault(vault_name)
|
|
579
|
+
unless blob.is_a?(String) && !blob.empty?
|
|
580
|
+
$stderr.puts "Error: Vault '#{vault_name}' has no cloud copy. Use: localvault sync push #{vault_name}"
|
|
581
|
+
return nil
|
|
582
|
+
end
|
|
583
|
+
data = SyncBundle.unpack(blob, expected_name: vault_name)
|
|
584
|
+
|
|
585
|
+
# Team vaults: only the owner holds the full plaintext and may push, so
|
|
586
|
+
# only the owner can merge. Members take the cloud copy instead.
|
|
587
|
+
handle = Config.inventlist_handle
|
|
588
|
+
if data[:owner] && data[:owner] != handle
|
|
589
|
+
my_slot = (data[:key_slots] || {})[handle]
|
|
590
|
+
access = my_slot.is_a?(Hash) && my_slot["scopes"].is_a?(Array) ? "scoped" : "member"
|
|
591
|
+
$stderr.puts "Error: '#{vault_name}' is a team vault owned by @#{data[:owner]}; you have #{access} access."
|
|
592
|
+
$stderr.puts "Only the owner can merge or push. Take the cloud copy with:"
|
|
593
|
+
$stderr.puts " localvault sync pull #{vault_name} --force"
|
|
594
|
+
return nil
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
merge_secrets(vault_name, store, master_key, data[:secrets], prefer: prefer, picks: picks)
|
|
598
|
+
rescue SyncBundle::UnpackError => e
|
|
599
|
+
$stderr.puts "Error: Could not parse cloud bundle for '#{vault_name}': #{e.message}"
|
|
600
|
+
nil
|
|
601
|
+
rescue Crypto::DecryptionError
|
|
602
|
+
$stderr.puts "Error: The cloud copy of '#{vault_name}' is encrypted with a different key (rekeyed or rotated elsewhere)."
|
|
603
|
+
$stderr.puts "Merge is not possible. Take one side:"
|
|
604
|
+
print_resolution_help(vault_name, nil, io: $stderr)
|
|
605
|
+
nil
|
|
606
|
+
rescue ApiClient::ApiError => e
|
|
607
|
+
if e.status == 404
|
|
608
|
+
$stderr.puts "Error: Vault '#{vault_name}' not found in cloud. Use: localvault sync push #{vault_name}"
|
|
609
|
+
nil
|
|
610
|
+
else
|
|
611
|
+
raise
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Same as +build_merge+ but never prompts and never prints — used by
|
|
616
|
+
# +sync all+ to enrich conflict output when the key is already cached.
|
|
617
|
+
#
|
|
618
|
+
# @return [SyncMerge::Result, nil]
|
|
619
|
+
def quiet_merge_preview(vault_name, client)
|
|
620
|
+
master_key = SessionCache.get(vault_name)
|
|
621
|
+
return nil unless master_key
|
|
622
|
+
store = Store.new(vault_name)
|
|
623
|
+
blob = client.pull_vault(vault_name)
|
|
624
|
+
return nil unless blob.is_a?(String) && !blob.empty?
|
|
625
|
+
remote_bytes = SyncBundle.unpack(blob)[:secrets]
|
|
626
|
+
merge_secrets(vault_name, store, master_key, remote_bytes)
|
|
627
|
+
rescue ApiClient::ApiError, SyncBundle::UnpackError, Crypto::DecryptionError,
|
|
628
|
+
SyncMerge::StructureError, JSON::ParserError
|
|
629
|
+
nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def merge_secrets(vault_name, store, master_key, remote_bytes, prefer: nil, picks: {})
|
|
633
|
+
base_bytes = SyncState.new(vault_name).read_base
|
|
634
|
+
base = base_bytes ? decrypt_secrets(base_bytes, master_key) : nil
|
|
635
|
+
local = decrypt_secrets(store.read_encrypted, master_key)
|
|
636
|
+
remote = decrypt_secrets(remote_bytes, master_key)
|
|
637
|
+
result = SyncMerge.merge(base, local, remote, prefer: prefer, picks: picks)
|
|
638
|
+
# Remember what needed a choice before picks so the CLI can validate them.
|
|
639
|
+
unresolved = (prefer || !picks.empty?) ? SyncMerge.merge(base, local, remote) : result
|
|
640
|
+
result.conflict_keys_before_picks = unresolved.conflicts.map(&:key)
|
|
641
|
+
result
|
|
642
|
+
rescue JSON::ParserError
|
|
643
|
+
# Never echo the parser's excerpt: it would contain decrypted bytes.
|
|
644
|
+
$stderr.puts "Error: decrypted secrets for '#{vault_name}' are not valid JSON (corrupt vault data). Merge aborted."
|
|
645
|
+
nil
|
|
646
|
+
rescue SyncMerge::StructureError => e
|
|
647
|
+
$stderr.puts "Error: cannot merge '#{vault_name}': #{e.message}."
|
|
648
|
+
$stderr.puts "Rename one of them on one side, or take a whole side:"
|
|
649
|
+
print_resolution_help(vault_name, nil, io: $stderr)
|
|
650
|
+
nil
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def decrypt_secrets(bytes, master_key)
|
|
654
|
+
return {} if bytes.nil? || bytes.empty?
|
|
655
|
+
JSON.parse(Crypto.decrypt(bytes, master_key))
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Print the key-level report. Only key names and change kinds appear.
|
|
659
|
+
def print_merge_report(result, indent:, io:)
|
|
660
|
+
lines = []
|
|
661
|
+
result.remote_changes.each { |c| lines << ["cloud", c.kind.to_s, c.key, "will take cloud"] }
|
|
662
|
+
result.local_changes.each { |c| lines << ["local", c.kind.to_s, c.key, "will keep local"] }
|
|
663
|
+
result.same_changes.each { |c| lines << ["both", c.kind.to_s, c.key, "identical, keep"] }
|
|
664
|
+
result.conflicts.each { |c| lines << ["CONFLICT", conflict_label(c.kind), c.key, "needs a choice"] }
|
|
665
|
+
|
|
666
|
+
if lines.empty?
|
|
667
|
+
io.puts "#{indent}no key differences"
|
|
668
|
+
return
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
w0 = lines.map { |l| l[0].length }.max
|
|
672
|
+
w1 = lines.map { |l| l[1].length }.max
|
|
673
|
+
w2 = lines.map { |l| l[2].length }.max
|
|
674
|
+
lines.each do |side, kind, key, note|
|
|
675
|
+
io.puts "#{indent}#{side.ljust(w0)} #{kind.ljust(w1)} #{key.ljust(w2)} #{note}"
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def conflict_label(kind)
|
|
680
|
+
case kind
|
|
681
|
+
when :local_deleted then "deleted here, changed in cloud"
|
|
682
|
+
when :remote_deleted then "changed here, deleted in cloud"
|
|
683
|
+
when :structure then "secret vs group, differs per side"
|
|
684
|
+
else "changed on both sides"
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Print the exact commands that resolve this vault's state.
|
|
689
|
+
def print_resolution_help(vault_name, result, io:, merge_only: false)
|
|
690
|
+
if result.nil? || result.clean?
|
|
691
|
+
io.puts " Merge (keeps every change from both sides):" unless merge_only
|
|
692
|
+
io.puts " localvault sync merge #{vault_name}"
|
|
693
|
+
else
|
|
694
|
+
keys = result.conflicts.map(&:key)
|
|
695
|
+
io.puts " Merge, choosing a side for the conflicting key#{keys.size == 1 ? "" : "s"}:"
|
|
696
|
+
io.puts " localvault sync merge #{vault_name} --prefer local"
|
|
697
|
+
io.puts " localvault sync merge #{vault_name} --prefer remote"
|
|
698
|
+
io.puts " Or pick per key:"
|
|
699
|
+
example = keys.size == 1 ? "--local #{keys.first}" : "--local #{keys.first} --remote #{keys[1]}"
|
|
700
|
+
io.puts " localvault sync merge #{vault_name} #{example}"
|
|
701
|
+
end
|
|
702
|
+
return if merge_only
|
|
703
|
+
io.puts " Or take one side entirely:"
|
|
704
|
+
io.puts " localvault sync push #{vault_name} (keep local, overwrite cloud)"
|
|
705
|
+
io.puts " localvault sync pull #{vault_name} --force (keep cloud, overwrite local)"
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def prompt_passphrase(msg = "Passphrase: ")
|
|
709
|
+
IO.console&.getpass(msg) || $stdin.gets&.chomp || ""
|
|
710
|
+
rescue Interrupt
|
|
711
|
+
$stderr.puts
|
|
712
|
+
""
|
|
713
|
+
end
|
|
714
|
+
|
|
465
715
|
def logged_in?
|
|
466
716
|
return true if Config.token
|
|
467
717
|
|
|
@@ -497,6 +747,45 @@ module LocalVault
|
|
|
497
747
|
{}
|
|
498
748
|
end
|
|
499
749
|
|
|
750
|
+
# Scoped members read a per-member blob, not the vault ciphertext, so a
|
|
751
|
+
# push must rebuild those blobs from the current plaintext or members
|
|
752
|
+
# keep seeing the pre-push values. Needs the master key; when the vault
|
|
753
|
+
# is locked the push is refused rather than publishing inconsistent
|
|
754
|
+
# views. Returns nil when the push must not proceed.
|
|
755
|
+
#
|
|
756
|
+
# @return [Hash, nil] refreshed key slots, or nil to abort the push
|
|
757
|
+
def refresh_scoped_slots(key_slots, store)
|
|
758
|
+
scoped = key_slots.select { |_, s| s.is_a?(Hash) && s["scopes"].is_a?(Array) && s["pub"].is_a?(String) }
|
|
759
|
+
return key_slots if scoped.empty?
|
|
760
|
+
|
|
761
|
+
master_key = SessionCache.get(store.vault_name)
|
|
762
|
+
unless master_key
|
|
763
|
+
$stderr.puts "Error: '#{store.vault_name}' has scoped members whose copies must be rebuilt on push, but the vault is locked."
|
|
764
|
+
$stderr.puts "Run: localvault unlock #{store.vault_name} && localvault sync push #{store.vault_name}"
|
|
765
|
+
return nil
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
vault = Vault.new(name: store.vault_name, master_key: master_key)
|
|
769
|
+
secrets = vault.all
|
|
770
|
+
scoped.each do |h, slot|
|
|
771
|
+
filtered = vault.filter(slot["scopes"], from: secrets)
|
|
772
|
+
member_key = RbNaCl::Random.random_bytes(32)
|
|
773
|
+
key_slots[h] = slot.merge(
|
|
774
|
+
"enc_key" => KeySlot.create(member_key, slot["pub"]),
|
|
775
|
+
"blob" => Base64.strict_encode64(Crypto.encrypt(JSON.generate(filtered), member_key))
|
|
776
|
+
)
|
|
777
|
+
rescue ArgumentError, KeySlot::DecryptionError
|
|
778
|
+
# A member whose stored public key is unusable could never decrypt
|
|
779
|
+
# anything anyway; keep their old slot rather than block the owner.
|
|
780
|
+
$stderr.puts " warning: @#{h}'s public key is invalid; their scoped copy was not refreshed."
|
|
781
|
+
end
|
|
782
|
+
key_slots
|
|
783
|
+
rescue Crypto::DecryptionError, JSON::ParserError => e
|
|
784
|
+
# Fixed message: a parser error's excerpt would contain decrypted bytes.
|
|
785
|
+
$stderr.puts "Error: could not read '#{store.vault_name}' to rebuild scoped members' copies (#{e.class.name.split("::").last}). Push refused."
|
|
786
|
+
nil
|
|
787
|
+
end
|
|
788
|
+
|
|
500
789
|
def bootstrap_owner_slot(key_slots, store)
|
|
501
790
|
return key_slots unless Identity.exists?
|
|
502
791
|
handle = Config.inventlist_handle
|
|
@@ -45,6 +45,10 @@ module LocalVault
|
|
|
45
45
|
rescue Crypto::DecryptionError
|
|
46
46
|
$stderr.puts "Error: Wrong passphrase for vault '#{vault_name}'."
|
|
47
47
|
nil
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
# Never echo the parser's excerpt: it would contain decrypted bytes.
|
|
50
|
+
$stderr.puts "Error: Vault '#{vault_name}' decrypted but its data is not valid JSON (corrupt vault data)."
|
|
51
|
+
nil
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
def load_key_slots(client, vault_name)
|
data/lib/localvault/cli.rb
CHANGED
|
@@ -501,6 +501,12 @@ module LocalVault
|
|
|
501
501
|
.render
|
|
502
502
|
|
|
503
503
|
$stdout.puts table
|
|
504
|
+
|
|
505
|
+
strays = Store.stray_entries
|
|
506
|
+
unless strays.empty?
|
|
507
|
+
$stderr.puts "Ignored #{strays.size} non-vault entr#{strays.size == 1 ? "y" : "ies"} in #{Config.vaults_path}: #{strays.map { |s| "'#{s}'" }.join(", ")}"
|
|
508
|
+
$stderr.puts "A vault is a folder named [a-z0-9_-] with a meta.yml inside."
|
|
509
|
+
end
|
|
504
510
|
end
|
|
505
511
|
|
|
506
512
|
desc "unlock [VAULT]", "Cache passphrase for session and output session token"
|
data/lib/localvault/store.rb
CHANGED
|
@@ -188,13 +188,40 @@ module LocalVault
|
|
|
188
188
|
# List all vault names found on disk.
|
|
189
189
|
#
|
|
190
190
|
# @return [Array<String>] sorted vault names
|
|
191
|
+
# Names of the vaults on disk: directories under +vaults/+ that have a
|
|
192
|
+
# valid vault name and a +meta.yml+. Anything else (a stray file, a
|
|
193
|
+
# hand-made folder with spaces in its name, a half-deleted vault) is
|
|
194
|
+
# skipped so callers can always +Store.new+ what they get back; see
|
|
195
|
+
# +stray_entries+ to surface those.
|
|
196
|
+
#
|
|
197
|
+
# @return [Array<String>] sorted vault names
|
|
191
198
|
def self.list_vaults
|
|
192
199
|
vaults_dir = Config.vaults_path
|
|
193
200
|
return [] unless File.directory?(vaults_dir)
|
|
194
201
|
|
|
195
202
|
Dir.children(vaults_dir)
|
|
196
|
-
.select { |name|
|
|
203
|
+
.select { |name| vault_dir?(vaults_dir, name) }
|
|
204
|
+
.sort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Entries under +vaults/+ that +list_vaults+ ignores, so a listing can
|
|
208
|
+
# tell the user why a folder they can see is not a vault.
|
|
209
|
+
#
|
|
210
|
+
# @return [Array<String>] sorted entry names
|
|
211
|
+
def self.stray_entries
|
|
212
|
+
vaults_dir = Config.vaults_path
|
|
213
|
+
return [] unless File.directory?(vaults_dir)
|
|
214
|
+
|
|
215
|
+
Dir.children(vaults_dir)
|
|
216
|
+
.reject { |name| name.start_with?(".") || vault_dir?(vaults_dir, name) }
|
|
197
217
|
.sort
|
|
198
218
|
end
|
|
219
|
+
|
|
220
|
+
def self.vault_dir?(vaults_dir, name)
|
|
221
|
+
name.match?(VAULT_NAME_PATTERN) && name.length <= 64 &&
|
|
222
|
+
File.directory?(File.join(vaults_dir, name)) &&
|
|
223
|
+
File.exist?(File.join(vaults_dir, name, "meta.yml"))
|
|
224
|
+
end
|
|
225
|
+
private_class_method :vault_dir?
|
|
199
226
|
end
|
|
200
227
|
end
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module LocalVault
|
|
4
|
+
# Three-way merge of vault secrets: base (last synced), local, remote.
|
|
5
|
+
#
|
|
6
|
+
# Works on flattened dot-notation keys (+"app.DB_URL"+) so groups and
|
|
7
|
+
# scalars compare uniformly. Values never leave this module in any report —
|
|
8
|
+
# callers get key names plus change kinds, which is all a human needs to
|
|
9
|
+
# decide, and all that is safe to print.
|
|
10
|
+
#
|
|
11
|
+
# Rules per key:
|
|
12
|
+
# - unchanged on both sides → keep
|
|
13
|
+
# - changed on one side only → take that side
|
|
14
|
+
# - changed on both sides to the same value → keep
|
|
15
|
+
# - changed on both sides to different values → conflict
|
|
16
|
+
#
|
|
17
|
+
# "changed" covers add, modify and delete relative to base. With no base
|
|
18
|
+
# (first sync between two pre-existing vaults) nothing can be proven
|
|
19
|
+
# deleted, so the merge is a union: keys on one side only are added, keys
|
|
20
|
+
# on both sides with different values are conflicts.
|
|
21
|
+
module SyncMerge
|
|
22
|
+
# Raised when merged keys cannot coexist in one nested hash, e.g. a scalar
|
|
23
|
+
# +app+ from one side and a group +app.X+ from the other.
|
|
24
|
+
class StructureError < StandardError; end
|
|
25
|
+
|
|
26
|
+
Change = Struct.new(:key, :kind, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# Result of a merge. +merged+ is the nested secrets hash ready to write;
|
|
29
|
+
# +conflicts+ is a list of keys that still need a human choice.
|
|
30
|
+
Result = Struct.new(:merged, :local_changes, :remote_changes, :conflicts, :same_changes,
|
|
31
|
+
:conflict_keys_before_picks, keyword_init: true) do
|
|
32
|
+
def clean?
|
|
33
|
+
conflicts.empty?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param base [Hash, nil] nested secrets at last sync, nil if unknown
|
|
38
|
+
# @param local [Hash] nested secrets on disk
|
|
39
|
+
# @param remote [Hash] nested secrets in the cloud bundle
|
|
40
|
+
# @param prefer [Symbol, nil] +:local+ or +:remote+ — resolves every conflict
|
|
41
|
+
# @param picks [Hash{String => Symbol}] per-key resolution, key => :local | :remote
|
|
42
|
+
# @return [Result]
|
|
43
|
+
def self.merge(base, local, remote, prefer: nil, picks: {})
|
|
44
|
+
b = base ? flatten(base) : nil
|
|
45
|
+
l = flatten(local)
|
|
46
|
+
r = flatten(remote)
|
|
47
|
+
|
|
48
|
+
merged = {}
|
|
49
|
+
local_changes = []
|
|
50
|
+
remote_changes = []
|
|
51
|
+
same_changes = []
|
|
52
|
+
conflicts = []
|
|
53
|
+
|
|
54
|
+
structural = structural_conflicts(base, local, remote)
|
|
55
|
+
structural.each do |root|
|
|
56
|
+
side = picks[root] || prefer
|
|
57
|
+
source = side == :remote ? r : l
|
|
58
|
+
source.each { |k, v| merged[k] = v if root_of(k) == root }
|
|
59
|
+
conflicts << Change.new(key: root, kind: :structure) unless %i[local remote].include?(side)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
(l.keys | r.keys | (b ? b.keys : [])).sort.each do |key|
|
|
63
|
+
next if structural.include?(root_of(key))
|
|
64
|
+
bv = b && b[key]
|
|
65
|
+
lv = l[key]
|
|
66
|
+
rv = r[key]
|
|
67
|
+
|
|
68
|
+
local_changed = b ? lv != bv : false
|
|
69
|
+
remote_changed = b ? rv != bv : false
|
|
70
|
+
|
|
71
|
+
if b.nil?
|
|
72
|
+
# No ancestor: union, conflict only when both present and different.
|
|
73
|
+
if lv && rv && lv != rv
|
|
74
|
+
resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
|
|
75
|
+
elsif lv.nil?
|
|
76
|
+
merged[key] = rv
|
|
77
|
+
remote_changes << Change.new(key: key, kind: :added)
|
|
78
|
+
elsif rv.nil?
|
|
79
|
+
merged[key] = lv
|
|
80
|
+
local_changes << Change.new(key: key, kind: :added)
|
|
81
|
+
else
|
|
82
|
+
merged[key] = lv
|
|
83
|
+
end
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if !local_changed && !remote_changed
|
|
88
|
+
merged[key] = lv unless lv.nil?
|
|
89
|
+
elsif local_changed && !remote_changed
|
|
90
|
+
merged[key] = lv unless lv.nil?
|
|
91
|
+
local_changes << Change.new(key: key, kind: kind_of(bv, lv))
|
|
92
|
+
elsif !local_changed && remote_changed
|
|
93
|
+
merged[key] = rv unless rv.nil?
|
|
94
|
+
remote_changes << Change.new(key: key, kind: kind_of(bv, rv))
|
|
95
|
+
elsif lv == rv
|
|
96
|
+
merged[key] = lv unless lv.nil?
|
|
97
|
+
same_changes << Change.new(key: key, kind: kind_of(bv, lv))
|
|
98
|
+
else
|
|
99
|
+
resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Result.new(
|
|
104
|
+
merged: unflatten(merged),
|
|
105
|
+
local_changes: local_changes,
|
|
106
|
+
remote_changes: remote_changes,
|
|
107
|
+
same_changes: same_changes,
|
|
108
|
+
conflicts: conflicts
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Flatten one level of grouping into dot keys. Values are stringified so
|
|
113
|
+
# comparison is by content, matching how +Vault#set+ stores them.
|
|
114
|
+
#
|
|
115
|
+
# @param hash [Hash] nested secrets
|
|
116
|
+
# @return [Hash{String => String}]
|
|
117
|
+
def self.flatten(hash)
|
|
118
|
+
out = {}
|
|
119
|
+
hash.each do |k, v|
|
|
120
|
+
if v.is_a?(Hash)
|
|
121
|
+
v.each { |sk, sv| out["#{k}.#{sk}"] = sv.to_s }
|
|
122
|
+
else
|
|
123
|
+
out[k.to_s] = v.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
out
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Inverse of +flatten+. Raises +StructureError+ when a scalar and a group
|
|
130
|
+
# share a name.
|
|
131
|
+
#
|
|
132
|
+
# @param flat [Hash{String => String}]
|
|
133
|
+
# @return [Hash]
|
|
134
|
+
def self.unflatten(flat)
|
|
135
|
+
out = {}
|
|
136
|
+
flat.each do |key, value|
|
|
137
|
+
if key.include?(".")
|
|
138
|
+
group, sub = key.split(".", 2)
|
|
139
|
+
out[group] ||= {}
|
|
140
|
+
raise StructureError, "'#{group}' is both a secret and a group" unless out[group].is_a?(Hash)
|
|
141
|
+
out[group][sub] = value
|
|
142
|
+
else
|
|
143
|
+
raise StructureError, "'#{key}' is both a secret and a group" if out[key].is_a?(Hash)
|
|
144
|
+
out[key] = value
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
out
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Roots whose shape (scalar / group / absent) differs between local and
|
|
151
|
+
# remote, with both sides having moved away from base. With no base, only
|
|
152
|
+
# a present-on-both scalar-vs-group disagreement is structural; a key
|
|
153
|
+
# missing on one side is an ordinary addition.
|
|
154
|
+
def self.structural_conflicts(base, local, remote)
|
|
155
|
+
roots = local.keys | remote.keys | (base ? base.keys : [])
|
|
156
|
+
roots.select do |root|
|
|
157
|
+
lk = shape(local[root])
|
|
158
|
+
rk = shape(remote[root])
|
|
159
|
+
next false if lk == rk
|
|
160
|
+
next(lk != :absent && rk != :absent) if base.nil?
|
|
161
|
+
bk = shape(base[root])
|
|
162
|
+
lk != bk && rk != bk
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.shape(value)
|
|
167
|
+
return :absent if value.nil?
|
|
168
|
+
value.is_a?(Hash) ? :group : :scalar
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.root_of(key)
|
|
172
|
+
key.split(".", 2).first
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.kind_of(before, after)
|
|
176
|
+
return :deleted if after.nil?
|
|
177
|
+
return :added if before.nil?
|
|
178
|
+
:modified
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.resolve_conflict(key, lv, rv, prefer, picks, merged, conflicts)
|
|
182
|
+
side = picks[key] || prefer
|
|
183
|
+
case side
|
|
184
|
+
when :local
|
|
185
|
+
merged[key] = lv unless lv.nil?
|
|
186
|
+
when :remote
|
|
187
|
+
merged[key] = rv unless rv.nil?
|
|
188
|
+
else
|
|
189
|
+
# Leave local in place so an unresolved merge never drops a key.
|
|
190
|
+
merged[key] = lv unless lv.nil?
|
|
191
|
+
conflicts << Change.new(key: key, kind: conflict_kind(lv, rv))
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Describe a conflict without values: which side deleted, or both modified.
|
|
196
|
+
def self.conflict_kind(lv, rv)
|
|
197
|
+
return :local_deleted if lv.nil?
|
|
198
|
+
return :remote_deleted if rv.nil?
|
|
199
|
+
:both_modified
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private_class_method :kind_of, :resolve_conflict, :conflict_kind, :structural_conflicts, :shape, :root_of
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -13,6 +13,12 @@ module LocalVault
|
|
|
13
13
|
class SyncState
|
|
14
14
|
FILENAME = ".sync_state"
|
|
15
15
|
|
|
16
|
+
# Ciphertext snapshot of secrets.enc as of the last successful sync. This
|
|
17
|
+
# is the common ancestor a three-way merge needs (see +SyncMerge+); the
|
|
18
|
+
# checksum alone only says *that* both sides drifted, not *which keys*.
|
|
19
|
+
# Same bytes and same file mode as secrets.enc, so it leaks nothing new.
|
|
20
|
+
BASE_FILENAME = ".sync_base"
|
|
21
|
+
|
|
16
22
|
attr_reader :vault_name
|
|
17
23
|
|
|
18
24
|
def initialize(vault_name)
|
|
@@ -23,10 +29,22 @@ module LocalVault
|
|
|
23
29
|
File.join(Config.vaults_path, vault_name, FILENAME)
|
|
24
30
|
end
|
|
25
31
|
|
|
32
|
+
def base_path
|
|
33
|
+
File.join(Config.vaults_path, vault_name, BASE_FILENAME)
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def exists?
|
|
27
37
|
File.exist?(path)
|
|
28
38
|
end
|
|
29
39
|
|
|
40
|
+
# @return [String, nil] encrypted secrets bytes as of the last sync, or nil
|
|
41
|
+
# when no snapshot was recorded (older clients, or an empty vault).
|
|
42
|
+
def read_base
|
|
43
|
+
return nil unless File.exist?(base_path)
|
|
44
|
+
bytes = File.binread(base_path)
|
|
45
|
+
bytes.empty? ? nil : bytes
|
|
46
|
+
end
|
|
47
|
+
|
|
30
48
|
# @return [Hash, nil] parsed YAML data or nil
|
|
31
49
|
def read
|
|
32
50
|
return nil unless exists?
|
|
@@ -46,8 +64,11 @@ module LocalVault
|
|
|
46
64
|
# Record a successful sync operation.
|
|
47
65
|
#
|
|
48
66
|
# @param checksum [String] SHA256 hex of the local secrets.enc
|
|
49
|
-
# @param direction [String] "push" or "
|
|
50
|
-
|
|
67
|
+
# @param direction [String] "push", "pull", "adopt" or "merge"
|
|
68
|
+
# @param base [String, nil] encrypted secrets bytes to snapshot as the
|
|
69
|
+
# merge ancestor. Pass the bytes that +checksum+ was computed from. nil
|
|
70
|
+
# (or empty) removes any previous snapshot.
|
|
71
|
+
def write!(checksum:, direction:, base: nil)
|
|
51
72
|
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
52
73
|
data = {
|
|
53
74
|
"last_synced_checksum" => checksum,
|
|
@@ -56,6 +77,25 @@ module LocalVault
|
|
|
56
77
|
}
|
|
57
78
|
File.write(path, YAML.dump(data))
|
|
58
79
|
File.chmod(0o600, path)
|
|
80
|
+
write_base!(base)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Record both checksum and ancestor snapshot from a store in one call.
|
|
84
|
+
#
|
|
85
|
+
# @param store [Store] vault store whose current secrets.enc is now synced
|
|
86
|
+
# @param direction [String] see +write!+
|
|
87
|
+
def record!(store, direction:)
|
|
88
|
+
bytes = store.read_encrypted
|
|
89
|
+
write!(checksum: self.class.local_checksum(store), direction: direction, base: bytes)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_base!(bytes)
|
|
93
|
+
if bytes.nil? || bytes.empty?
|
|
94
|
+
FileUtils.rm_f(base_path)
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
File.binwrite(base_path, bytes)
|
|
98
|
+
File.chmod(0o600, base_path)
|
|
59
99
|
end
|
|
60
100
|
|
|
61
101
|
# Compute the SHA256 hex digest of a vault's local secrets.enc.
|
data/lib/localvault/vault.rb
CHANGED
|
@@ -111,6 +111,24 @@ module LocalVault
|
|
|
111
111
|
end
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# Replace the entire secrets hash. Used by sync merge, which computes the
|
|
115
|
+
# full merged result and needs deletions applied as well as additions.
|
|
116
|
+
#
|
|
117
|
+
# @param secrets [Hash] nested secrets hash (groups as nested hashes)
|
|
118
|
+
# @return [void]
|
|
119
|
+
# @raise [InvalidKeyName] when any key contains invalid characters
|
|
120
|
+
def replace(secrets)
|
|
121
|
+
secrets.each do |k, v|
|
|
122
|
+
if v.is_a?(Hash)
|
|
123
|
+
validate_key_segment!(k)
|
|
124
|
+
v.each_key { |sk| validate_key_segment!(sk) }
|
|
125
|
+
else
|
|
126
|
+
validate_key!(k)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
write_secrets(secrets)
|
|
130
|
+
end
|
|
131
|
+
|
|
114
132
|
# Returns a sorted flat list of all keys. Nested keys use dot-notation.
|
|
115
133
|
#
|
|
116
134
|
# @return [Array<String>] sorted key names, e.g. ["API_KEY", "myapp.DB_URL"]
|
data/lib/localvault/version.rb
CHANGED
data/lib/localvault.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: localvault
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/localvault/stdin_secret_input.rb
|
|
138
138
|
- lib/localvault/store.rb
|
|
139
139
|
- lib/localvault/sync_bundle.rb
|
|
140
|
+
- lib/localvault/sync_merge.rb
|
|
140
141
|
- lib/localvault/sync_state.rb
|
|
141
142
|
- lib/localvault/vault.rb
|
|
142
143
|
- lib/localvault/vault_resolver.rb
|