ansible-vars 1.0.0__py3-none-any.whl

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.
ansible_vars/vault.py ADDED
@@ -0,0 +1,830 @@
1
+ # Vault(file) parsing and management for ansible-vars
2
+
3
+ # Standard library imports
4
+ import os, re, json
5
+ from io import StringIO
6
+ from functools import reduce
7
+ from typing import Type, Hashable, Callable, Any
8
+ from types import MappingProxyType
9
+ from collections import OrderedDict
10
+ from difflib import unified_diff
11
+
12
+ # External library imports
13
+ from ruamel.yaml import YAML
14
+ from ruamel.yaml.nodes import ScalarNode
15
+ from ruamel.yaml.parser import CommentToken
16
+ from ruamel.yaml.representer import Representer
17
+ from ruamel.yaml.constructor import Constructor
18
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
19
+
20
+ # Internal module imports
21
+ from .constants import ThrowError, octal, Indexable, ChangeList, MatchLocation, SENTINEL_KEY, EDIT_MODE_HEADER, ENCRYPTED_VAR_TAG
22
+ from .vault_crypt import VaultKey, VaultKeyring
23
+ from .errors import KeyExistsError, NoVaultKeysError, YAMLFormatError
24
+
25
+ class EncryptedVar():
26
+ '''
27
+ Represents a single encrypted vault variable, initialized with the encrypted content.
28
+ The content should be a str, as Ansible does not directly support other data types in encrypted variables.
29
+ As this class has no `VaultKeyring` access, decryption must be performed externally.
30
+ Note that comparing `EncryptedVar` objects by their `cipher`s usually does not work, as Ansible ciphers contain a random salt.
31
+ '''
32
+
33
+ def __init__(self, cipher: str, name: str | None = None) -> None:
34
+ '''Initialize an encrypted variable with an optional variable name. The name is only used for internal representation.'''
35
+ # Encrypted has to hold a string like '$ANSIBLE_VAULT;1.2;AES256;ramiio\n123456<...>' (the newline is important)
36
+ self.cipher: str = cipher
37
+ self.name: str | None = name
38
+
39
+ def __repr__(self) -> str:
40
+ return f"EncryptedVar({ self.name or 'unnamed' })"
41
+
42
+ # ruamel.yaml dumper/loader converters
43
+
44
+ yaml_tag: str = u'!vault'
45
+
46
+ @classmethod
47
+ def to_yaml(EncryptedVar: Type['EncryptedVar'], representer: Representer, var: 'EncryptedVar') -> Any:
48
+ #return representer.represent_str(var.cipher)
49
+ return representer.represent_scalar(u'!vault', var.cipher, style='|')
50
+
51
+ @classmethod
52
+ def from_yaml(EncryptedVar: Type['EncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'EncryptedVar':
53
+ cipher: Any = constructor.construct_scalar(node)
54
+ if not isinstance(cipher, str):
55
+ raise TypeError(f"Expected encrypted value to be a str, but got { type(cipher) }")
56
+ return EncryptedVar(str(cipher), name=node.id)
57
+
58
+ class ProtoEncryptedVar():
59
+ '''A variable marked to be encrypted in a `Vault` editable.'''
60
+
61
+ def __init__(self, plaintext: str, name: str) -> None:
62
+ '''Initialize a plaintext value marked for encryption with a name for internal representation.'''
63
+ self.plaintext: str = plaintext
64
+ self.name: str = name
65
+
66
+ def __eq__(self, __o: object) -> bool:
67
+ if type(__o) is not ProtoEncryptedVar:
68
+ return False
69
+ return self.plaintext == __o.plaintext
70
+
71
+ def __repr__(self) -> str:
72
+ return f"ProtoEncryptedVar({ self.name })"
73
+
74
+ # ruamel.yaml dumper/loader converters
75
+
76
+ yaml_tag: str = ENCRYPTED_VAR_TAG
77
+
78
+ @classmethod
79
+ def to_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], representer: Representer, var: 'ProtoEncryptedVar') -> Any:
80
+ return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style='\'')
81
+
82
+ @classmethod
83
+ def from_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'ProtoEncryptedVar':
84
+ plaintext: Any = constructor.construct_scalar(node)
85
+ if not isinstance(plaintext, str):
86
+ raise TypeError(f"Expected decrypted value to be a str, but got { type(plaintext) }")
87
+ return ProtoEncryptedVar(str(plaintext).rstrip('\n'), name=node.id)
88
+
89
+ class Vault():
90
+ '''
91
+ Represents an Ansible vault's contents, with plain and encrypted variables and potentially full vault encryption on top.
92
+ Since full vault encryption is detected from the specified yaml content, do not externally decrypt the vault contents first
93
+ unless you wish to lose full file encryption, or set `Vault.full_encryption` manually after initialization.
94
+ To create a fresh `Vault`, you may also use `Vault.create` and set your desired encryption settings directly.
95
+ To load from and save to a vault file directly, use the `VaultFile` wrapper of this module.
96
+
97
+ Note that variable values containing Jinja2 code (e.g. `my_var: "{{ other_var }}"`) are stored in an escaped form by ruamel.yaml.jinja2.
98
+ You should either ignore these values or take care to understand the escape syntax of the plugin before editing them or adding any.
99
+ They can be freely overwritten with non-Jinja2 values and will lose their special status.
100
+ '''
101
+
102
+ # Initialization/Loading
103
+
104
+ def __init__(self, yaml_content: str, keyring: VaultKeyring | None = None) -> None:
105
+ '''
106
+ Parses a vault's (potentially encrypted) contents. Automatically detects if the content is wholly encrypted.
107
+ If no keyring is supplied, only plain vars and content are supported.
108
+ '''
109
+ # If no keyring is supplied, create an empty one which will raise an Error if we try to en-/decrypt anything
110
+ self.keyring: VaultKeyring = keyring or VaultKeyring(keys=None, detect_available_keys=False)
111
+ # Full vault encryption, may also contain single encrypted variables either way
112
+ self.full_encryption: bool
113
+ # Internal variable tree
114
+ # Contains a sentinel key (constants.SENTINEL_KEY)
115
+ self._data: CommentedMap
116
+ # YAML parser, contains YAML metadata needed for exporting the data
117
+ self._parser: YAML
118
+ # Parse the given Jinja2 yaml data and set the relevant variables
119
+ self._parser, self._data, self.full_encryption = self._parse(yaml_content, self.keyring)
120
+
121
+ @classmethod
122
+ def create(Vault: Type['Vault'], content: str = '', full_encryption: bool = True, keyring: VaultKeyring | None = None) -> 'Vault':
123
+ '''
124
+ Creates a new vault with desired encryption settings.
125
+ If `content` is set, the vault will be loaded with this Jinja2 yaml text instead of an empty string.
126
+ If `full_encryption` is set to True, the vault will be wholly encrypted in addition to any encrypted variables.
127
+ If no keyring is supplied, only plain vars are supported and enabling `full_encryption` will not work.
128
+ '''
129
+ vault = Vault(content, keyring=keyring)
130
+ vault.full_encryption = full_encryption
131
+ return vault
132
+
133
+ @classmethod
134
+ def from_editable(Vault: Type['Vault'], prev_vault: 'Vault', edited_content: str) -> 'Vault':
135
+ '''Converts a YAML vault edited from a `Vault.as_editable` template into a new `Vault`.'''
136
+ # Remove static header (try removing without trailing newline too just in case)
137
+ edited_content = edited_content.replace(EDIT_MODE_HEADER, '', 1)
138
+ edited_content = edited_content.replace(EDIT_MODE_HEADER.strip('\n'), '', 1)
139
+ # Init new vault with edited YAML
140
+ # ProtoEncryptedVar conversion is done in vault parser, so no need to do it here
141
+ vault = Vault(edited_content, keyring=prev_vault.keyring)
142
+ # Copy relevant settings from old vault
143
+ vault.full_encryption = prev_vault.full_encryption
144
+ return vault
145
+
146
+ @staticmethod
147
+ def _parse(yaml_content: str, keyring: VaultKeyring) -> tuple[YAML, CommentedMap, bool]:
148
+ '''
149
+ Parses the given Jinja2 yaml string into a CommentedMap.
150
+ Returns the YAML parser holding metadata, the loaded mapping and a flag signifying the string was fully encrypted.
151
+ '''
152
+ # Create a special Jinja2 YAML round-trip parser holding the metadata for Jinja2 blocks, comments and (most) formatting
153
+ parser = YAML(typ='jinja2')
154
+ parser.allow_unicode = True
155
+ parser.preserve_quotes = True
156
+ parser.allow_duplicate_keys = True
157
+ # mapping = dict offset, sequence = list offset within item, offset = list offset before dash
158
+ parser.indent(sequence=2, mapping=2, offset=0)
159
+ # Automatic loading and dumping of EncryptedVars
160
+ parser.register_class(EncryptedVar)
161
+ # Automcatic loading and dumping of encryption marks
162
+ parser.register_class(ProtoEncryptedVar)
163
+ # Decrypt file if wholly encrypted
164
+ if (full_encryption := VaultKey.is_encrypted(yaml_content)):
165
+ yaml_content = keyring.decrypt(yaml_content)
166
+ # Insert a dummy key because our parser can't save the comments of a file without any keys
167
+ yaml_content = Vault._insert_sentinel(yaml_content)
168
+ # Load YAML as CommentedMap
169
+ try:
170
+ data: CommentedMap = parser.load(yaml_content)
171
+ except Exception as e:
172
+ raise YAMLFormatError('Provided content is not valid Ansible YAML code', parent=e)
173
+ # Convert ProtoEncryptedVars to EncryptedVars
174
+ # This is done in parsing instead of in `Vault.from_editable` in case the user creates a vault with protovars
175
+ def _convert_from_proto(_: tuple[Hashable, ...], value: Any) -> Any:
176
+ '''Encrypts leaves marked for encryption.'''
177
+ if type(value) is not ProtoEncryptedVar:
178
+ return value
179
+ return EncryptedVar(keyring.encrypt(value.plaintext), name=value.name)
180
+ Vault._transform_leaves(data, _convert_from_proto, tuple())
181
+ return parser, data, full_encryption
182
+
183
+ @staticmethod
184
+ def _insert_sentinel(yaml_content: str) -> str:
185
+ '''Inserts a special key into the given raw YAML string, so the resulting data cannot be empty. Returns the modified YAML.'''
186
+ lines: list[str] = yaml_content.split('\n')
187
+ # Insert our sentinel key right before the explicit end line (`...`)
188
+ for index, line in reversed(list(enumerate(lines))): # look for last occurance
189
+ if line.strip() == '...':
190
+ lines.insert(index, f"{ SENTINEL_KEY }:")
191
+ break
192
+ # If there is no explicit end, just append it to the text
193
+ else:
194
+ lines.append(f"{ SENTINEL_KEY }:")
195
+ return '\n'.join(lines)
196
+
197
+ # Dictionary operations
198
+
199
+ # A single dict key or a chain of nested dict keys
200
+ DictPath = Hashable | tuple[Hashable, ...]
201
+
202
+ @property
203
+ def decrypted_vars(self) -> MappingProxyType:
204
+ '''
205
+ A read-only dictionary of the vault's variables, with any EncryptedVars already decrypted.
206
+ Note that the state is frozen whenever you access this property, and not updated when the vault changes.
207
+ '''
208
+ copy: dict = self._data.copy()
209
+ copy.pop(SENTINEL_KEY, None)
210
+ def _traverse_and_decrypt(root: Any) -> Any:
211
+ if isinstance(root, dict):
212
+ return { k: _traverse_and_decrypt(v) for k, v in root.items() }
213
+ elif isinstance(root, list):
214
+ return [ _traverse_and_decrypt(v) for v in root ]
215
+ else:
216
+ return self.keyring.decrypt(root.cipher) if type(root) is EncryptedVar else root
217
+ return MappingProxyType(_traverse_and_decrypt(copy))
218
+
219
+ def has(self, path: DictPath) -> bool:
220
+ '''Checks if the given key path is present in the vault's data.'''
221
+ try: self._traverse(path, decrypt=False)
222
+ except KeyError: return False
223
+ return True
224
+
225
+ def get(
226
+ self, path: DictPath, default: Any | Type[ThrowError] = ThrowError,
227
+ decrypt: bool = False, with_index: bool = False
228
+ ) -> Any | tuple[int, Any]:
229
+ '''
230
+ Retrieves a value from the vault's variables by its key path, optionally decrypting it (but not its children).
231
+ When `default` is set to `ThrowError`, a `KeyError` will be raised if the path does not exist.
232
+ Else, the default value is returned if the path does not exist.
233
+ When `with_index` is set to True, a tuple `(index_in_parent, value)` is returned (-1 if defaulted).
234
+ '''
235
+ path = Vault._to_path(path)
236
+ try:
237
+ value: Any = self._traverse(path, decrypt=decrypt)
238
+ if with_index:
239
+ parent: Indexable = self._traverse(path[:-1], decrypt=False)
240
+ if isinstance(parent, dict):
241
+ key_index: int = next(index for index, key in enumerate(parent) if key == path[-1])
242
+ else:
243
+ key_index: int = path[-1] # type: ignore
244
+ return (key_index, value)
245
+ return value
246
+ except:
247
+ if default is ThrowError:
248
+ raise KeyError(f"Key path { '.'.join(map(str, path)) } could not be found in { self }")
249
+ return (-1, default) if with_index else default
250
+
251
+ def set(
252
+ self, path: DictPath, value: Any, overwrite: bool | Type[ThrowError] = True,
253
+ create_parents: bool | Type[ThrowError] = True, encrypt: bool = False
254
+ ) -> bool:
255
+ '''
256
+ Creates or updates a value in the vault's variables. The value has to be serializable into YAML.
257
+ If the last/only key of the key path does not exist yet, it will be created.
258
+ If the variable is set successfully, `True` is returned. If any of the below checks fail, `False` is returned.
259
+ Be aware that appending a new entry to a list requires the key to be equal to the length of the list (i.e. largest index + 1).
260
+ On updated leaf values, comment and Jinja2 metadata is preserved.
261
+ When appending a new value or editing an indexable item, metadata may get messed up.
262
+
263
+ Options:
264
+ - `overwrite`: Controls what happens if a value is already present at the key path
265
+ - `True`: Replace the existing value
266
+ - `False`: Abort silently, returning False
267
+ - `ThrowError`: Raise a KeyError if the key path already exists
268
+ - `create_parents`: Controls if the entire key path should be created if it doesn't exist yet
269
+ - `True`: Create any nested dictionaries needed to traverse to the last key
270
+ - `False`: Abort silently if any but the last key in the path do not exist, returning False
271
+ - `ThrowError`: Raise a KeyError if any but the last key in the path do not exist
272
+ - `encrypt`: Controls if the value should be encrypted before storing it (value has to be a str)
273
+ - `True`: Attempt to convert the value into an `EncryptedVar` before storing it
274
+ - `False`: Store the value as-is
275
+ '''
276
+ path = Vault._to_path(path)
277
+ # Convert value to EncryptedVar if neccessary
278
+ if encrypt and type(value) is not EncryptedVar:
279
+ if type(value) is not str:
280
+ raise TypeError(f"Ansible only supports encrypted str values, got { type(value) } for { '.'.join(map(str, path)) }")
281
+ if VaultKey.is_encrypted(value):
282
+ value = EncryptedVar(value, name=str(path[-1]))
283
+ else:
284
+ value = EncryptedVar(self.keyring.encrypt(value), name=str(path[-1]))
285
+ # Resolve chain and create parents if necessary, then set value for last item
286
+ parent: Any = self._data
287
+ par_path: str = ''
288
+ for _index, segment in enumerate(path):
289
+ is_last: bool = (_index + 1) == len(path)
290
+ # Check if parent is indexable
291
+ if not isinstance(parent, dict | list):
292
+ raise TypeError(f"Indexing into a { type(parent) } is not supported ({ par_path })")
293
+ # Check if index is of correct type
294
+ if isinstance(parent, list) and type(segment) is not int:
295
+ raise TypeError(f"Type of list index has to be int, got { type(segment) } ({ par_path }[{ segment }])")
296
+ # Check if the current segment has to be created in the parent
297
+ if (isinstance(parent, dict) and segment not in parent) or (isinstance(parent, list) and segment >= len(parent)): # type: ignore
298
+ if not is_last:
299
+ if create_parents is ThrowError:
300
+ raise KeyError(f"Parents of { '.'.join(map(str, path)) } could not be resolved ({ segment } not in { par_path })")
301
+ if not create_parents:
302
+ return False
303
+ # Create nested dictionary as next parent or set value of leaf, depending on index
304
+ segment_value: Any = value if is_last else CommentedMap()
305
+ # Check that a new list item has a specified index of (largest list index + 1)
306
+ # This is done because the method of creating an index of e.g. 7 in a list of length 3 is ambiguous
307
+ if isinstance(parent, list) and segment != len(parent):
308
+ raise IndexError(f"Creating new list item, but index { segment } exceeds appendment index { len(parent) } ({ par_path })")
309
+ # Set value
310
+ if isinstance(parent, dict):
311
+ parent[segment] = segment_value # type: ignore
312
+ else:
313
+ parent.append(segment_value)
314
+ # Else, replace the existing value if we are at the end of the path
315
+ elif is_last:
316
+ if overwrite is ThrowError:
317
+ raise KeyExistsError(f"Key { segment } already exists in { par_path }")
318
+ if not overwrite:
319
+ return False
320
+ parent[segment] = value # type: ignore
321
+ # Advance parent
322
+ parent = parent[segment] # type: ignore
323
+ par_path = f"{ par_path }.{ segment }"
324
+ return True
325
+
326
+ def pop(
327
+ self, path: DictPath, default: Any | Type[ThrowError] = ThrowError,
328
+ decrypt: bool = False, with_index: bool = False
329
+ ) -> Any | tuple[int, Any]:
330
+ '''
331
+ Pops (i.e. removes and returns) a value from the vault's variables, optionally decrypting it.
332
+ Attempts to preserve metadata like comments and Jinja2 blocks.
333
+ When `default` is set to `ThrowError`, a `KeyError` will be raised if the path does not exist.
334
+ Else, the default value is returned if the path does not exist.
335
+ When `with_index` is set to True, a tuple `(index_in_parent, value)` is returned (-1 if defaulted).
336
+ Be aware that this method is experimental and may mess up comment and Jinja2 metadata.
337
+ '''
338
+ path = Vault._to_path(path)
339
+ index, value = self.get(path, default=default, decrypt=decrypt, with_index=True)
340
+ # If we did not receive the default value back, we have to delete the key and move its metadata
341
+ if index > -1:
342
+ parent: Indexable = self._traverse(path[:-1], decrypt=False)
343
+ # XXX Metadata manipulation is broken
344
+ """
345
+ # XXX
346
+ # When a key is deleted, all following comments (not just EOL) up to the next var will be deleted.
347
+ # We need to move these comment tokens from key_parent.ca.items[key] to the previous item.
348
+ # If there is not previous item, we need to merge them into key_parent.ca.comment.
349
+ # XXX Problem we have not adressed yet:
350
+ # When deleting a list or dict, the following comments/Jinja2 are actually part of the deepest last element's metadata,
351
+ # not the root elem
352
+ if isinstance(value, dict | list):
353
+ def _find_last_child(_path: tuple[Hashable, ...]) -> tuple[Hashable, ...]:
354
+ '''Finds the child of an indexable that is printed last.'''
355
+ _value: Any = self._traverse(_path, decrypt=False)
356
+ if isinstance(_value, dict) and _value:
357
+ return _find_last_child(_path + ( list(_value.keys())[-1], ))
358
+ if isinstance(_value, list) and _value:
359
+ return _find_last_child(_path + ( len(_value) - 1, ))
360
+ return _path
361
+ _last_path: tuple[Hashable, ...] = _find_last_child(path)
362
+ _last_parent: Indexable = self._traverse(_last_path[:-1], decrypt=False)
363
+ else:
364
+ _last_path = path
365
+ _last_parent = parent
366
+ # First, we need to check if there is a previous key we can move the metadata to
367
+ if index > 0:
368
+ # If the parent can hold metadata, we can assume it is ordered (CommentedMap | CommentedSeq)
369
+ prev_key = (list(parent.keys())[index - 1]) if isinstance(parent, dict) else (parent[index - 1])
370
+ self._move_metadata(_last_path, path[:-1] + ( prev_key, ), merge=True)
371
+ # If there is no previous key and the key holds metadata, merge the metadata with the parent's base comment
372
+ elif isinstance(_last_parent, CommentedMap | CommentedSeq) and path[-1] in _last_parent.ca.items:
373
+ # Anchor #2 holds EOL comments, anchor #3 holds next-line comments (usually merged into #2 if both exist)
374
+ # We can ignore #0 and #1 (I think)
375
+ if _last_parent.ca.items[_last_path[-1]][2] or _last_parent.ca.items[_last_path[-1]][3]:
376
+ new_metadata: list = (_last_parent.ca.items[_last_path[-1]][2] or []) + (_last_parent.ca.items[_last_path[-1]][3] or [])
377
+ parent.ca.comment = [ parent.ca.comment[0], (parent.ca.comment[1] or []) + new_metadata ]
378
+ """
379
+ # Remove value
380
+ del parent[path[-1]] # type: ignore
381
+ return (index, value) if with_index else value
382
+
383
+ # XXX Deprecated due to parser issues
384
+ """
385
+ def rename_key(self, path: DictPath, rename_to: Hashable, overwrite: bool | Type[ThrowError] = ThrowError) -> bool:
386
+ '''
387
+ Renames a key, preserving its position and metadata. Returns True on success. Only works for dict-like parents.
388
+
389
+ `overwrite` controls what happens if a value is already present at the new key path:
390
+ - `True`: Replace the existing key's value
391
+ - `False`: Abort silently, returning False
392
+ - `ThrowError`: Raise a KeyError if the new key path already exists
393
+ '''
394
+ path = Vault._to_path(path)
395
+ parent: CommentedMap = self._traverse(path[:-1], decrypt=False)
396
+ if not isinstance(parent, dict):
397
+ raise TypeError('Can only rename keys in dict-like structures.')
398
+ # Check if the old key exists
399
+ if path[-1] not in parent:
400
+ raise KeyError(f"Key path { '.'.join(map(str, path)) } could not be found in { self }")
401
+ # Check if the new key already exists
402
+ if rename_to in parent:
403
+ if overwrite is False:
404
+ return False
405
+ if overwrite is ThrowError:
406
+ raise KeyError(f"Rename target { '.'.join(map(str, path[:-1] + ( rename_to, ))) } already exists")
407
+ # Find index of old key
408
+ key_index: int = next(index for index, key in enumerate(parent) if key == path[-1])
409
+ # Rename key and move metadata
410
+ self._move_metadata(path, path[:-1] + ( rename_to, ))
411
+ parent.insert(key_index, rename_to, parent.pop(path[-1]))
412
+ return True
413
+
414
+ # XXX This method is pretty much broken, as we would need to perform complex merging
415
+ # for the multiple comment types and parent comments
416
+ def _move_metadata(self, old_path: DictPath, new_path: DictPath, merge: bool = False) -> None:
417
+ '''
418
+ Moves the metadata (i.e. comments and Jinja2) of an item to another item.
419
+ If `merge` is set to False, the new item's metadata will be overwritten.
420
+ Else, it will be merged with the old item's metadata (in order new, old).
421
+ The parent of the new item has to exist already, the new item itself doesn't have to.
422
+ '''
423
+ old_path, new_path = Vault._to_path(old_path), Vault._to_path(new_path)
424
+ # Get parent dict of old key, which contains the key metadata
425
+ old_parent: Indexable = self._traverse(old_path[:-1], decrypt=False)
426
+ # Get parent dict of new key, which will receive the key metadata
427
+ new_parent: Indexable = self._traverse(new_path[:-1], decrypt=False)
428
+ # If the old parent has no capability of storing metadata, return
429
+ if not isinstance(old_parent, CommentedMap | CommentedSeq):
430
+ return
431
+ # Check if the old key's parent has any CA metadata for the key, else we're already done
432
+ if old_path[-1] not in old_parent.ca.items:
433
+ return
434
+ # If the new parent has no capability of receiving metadata, raise an error
435
+ if not isinstance(new_parent, CommentedMap | CommentedSeq):
436
+ raise TypeError(f"New parent { '.'.join(map(str, new_parent)) } cannot hold any metadata")
437
+ # Check if we should merge metadata (old is appended to new as we assume the old item comes after the new one)
438
+ if merge and new_path[-1] in new_parent.ca.items and new_parent.ca.items[new_path[-1]]:
439
+ # The metadata consists of a list with each item being either None, a CommentToken or a list of CommentTokens
440
+ old_metadata: list = old_parent.ca.items.pop(old_path[-1])
441
+ merged_metadata: list = new_parent.ca.items[new_path[-1]]
442
+ print(merged_metadata, old_metadata)
443
+ for index, _ in enumerate(merged_metadata):
444
+ if old_metadata[index]:
445
+ _merged = merged_metadata[index]
446
+ _old = old_metadata[index]
447
+ print(_merged, _old)
448
+ # Direct overwrite
449
+ if not _merged:
450
+ merged_metadata[index] = _old
451
+ # Direct token merge if both are tokens
452
+ elif type(_merged) == type(_old) == CommentToken:
453
+ merged_metadata[index] = CommentToken(
454
+ value = _merged.value + _old.value,
455
+ start_mark = _merged.start_mark,
456
+ end_mark = _merged.end_mark,
457
+ column = _merged.column
458
+ )
459
+ # Convert to lists and merge those
460
+ else:
461
+ _merged = _merged or []
462
+ _merged = _merged if type(_merged) is list else [ _merged ]
463
+ _old = _old or []
464
+ _old = _old if type(_old) is list else [ _old ]
465
+ merged_metadata[index] = _merged + _old
466
+ print(merged_metadata)
467
+ else:
468
+ # Overwrite the CA (comment anchor) metadata, which includes Jinja2 blocks
469
+ new_parent.ca.items[new_path[-1]] = old_parent.ca.items.pop(old_path[-1])
470
+ """
471
+
472
+ def _traverse(self, path: DictPath, decrypt: bool = False) -> Any:
473
+ '''Gets the value of the specified key path from the `Vault`'s `_data`, optionally decrypting it.'''
474
+ path = Vault._to_path(path)
475
+ def _get_child(parent: Indexable, index: Hashable) -> Any:
476
+ if not isinstance(parent, dict | list):
477
+ raise TypeError(f"Can only index into dict-like and list-like types, got { type(parent) } for index { index }")
478
+ is_dict: bool = isinstance(parent, dict)
479
+ if (is_dict and index not in parent) or (not is_dict and index > len(parent)): # type: ignore
480
+ raise KeyError(f"Key '{ index }' of path '{ '.'.join(map(str, path)) }' could not be resolved")
481
+ return parent[index] # type: ignore
482
+ leaf: Any = reduce(_get_child, path, self._data)
483
+ return self.keyring.decrypt(leaf.cipher) if (decrypt and type(leaf) is EncryptedVar) else leaf
484
+
485
+ @staticmethod
486
+ def _to_path(path: DictPath) -> tuple[Hashable, ...]:
487
+ '''Create a tuple of dictionary keys for traversing nested dictionaries. Can be initialized with a single key or a tuple.'''
488
+ return path if isinstance(path, tuple) else ( path, )
489
+
490
+ # Output formats
491
+
492
+ def as_json(self) -> str:
493
+ '''Returns the decrypted variables of the vault as a JSON string.'''
494
+ return json.dumps(dict(self.decrypted_vars), indent=2)
495
+
496
+ def as_plain(self) -> str:
497
+ '''Returns the vault in fully decrypted form as Jinja2 YAML code with the original metadata.'''
498
+ copy: CommentedMap = self._data.copy()
499
+ #copy.pop(SENTINEL_KEY, None) # <-- would break a file containing only metadata and the sentinel key
500
+ def _decrypt_leaf(_: tuple[Hashable, ...], value: Any) -> Any:
501
+ '''Transforms EncryptedVar leaves into decrypted strings.'''
502
+ return self.keyring.decrypt(value.cipher) if type(value) is EncryptedVar else value
503
+ Vault._transform_leaves(copy, _decrypt_leaf, tuple())
504
+ yaml_content: str = self._dump_to_str(copy)
505
+ yaml_content = Vault._remove_sentinel(yaml_content)
506
+ return yaml_content
507
+
508
+ def as_editable(self, with_header: bool = True) -> str:
509
+ '''
510
+ Returns the vault as Jinja2 YAML code with the original metadata.
511
+ It is prepared for editing and later re-encryption with YAML tags and a static explanatory header.
512
+ '''
513
+ copy: CommentedMap = self._data.copy()
514
+ def _convert_to_proto(path: tuple[Hashable, ...], value: Any) -> Any:
515
+ '''Marks encrypted leaves for encryption after editing.'''
516
+ if type(value) is not EncryptedVar:
517
+ return value
518
+ return ProtoEncryptedVar(self.keyring.decrypt(value.cipher), value.name or str(path[-1]))
519
+ Vault._transform_leaves(copy, _convert_to_proto, tuple())
520
+ yaml_content: str = self._dump_to_str(copy)
521
+ yaml_content = Vault._remove_sentinel(yaml_content)
522
+ # Add static header
523
+ if with_header:
524
+ return EDIT_MODE_HEADER + yaml_content
525
+ return yaml_content
526
+
527
+ def as_encrypted(self) -> str:
528
+ '''Returns the vault as Jinja2 YAML code with the original metadata, with encrypted variables and full encryption if enabled.'''
529
+ copy: CommentedMap = self._data.copy()
530
+ yaml_content: str = self._dump_to_str(copy)
531
+ yaml_content = Vault._remove_sentinel(yaml_content)
532
+ if self.full_encryption:
533
+ yaml_content = self.keyring.encrypt(yaml_content)
534
+ return yaml_content
535
+
536
+ @staticmethod
537
+ def _transform_leaves(indexable: Indexable, transform_fn: Callable[[tuple[Hashable, ...], Any], Any], curr_path: tuple[Hashable, ...]) -> None:
538
+ '''
539
+ Runs the transform_fn on all leaves of the indexable object recursively, passing the current path and the leaf object as a tuple.
540
+ The leaves are replaced by the result of the function call.
541
+ '''
542
+ if isinstance(indexable, dict):
543
+ keys: list[Hashable] = list(indexable.keys())
544
+ else:
545
+ keys: list[Hashable] = list(range(len(indexable)))
546
+ for key in keys:
547
+ _curr_path = curr_path + ( key, ) # type: ignore
548
+ if isinstance(indexable[key], dict | list): # type: ignore
549
+ Vault._transform_leaves(indexable[key], transform_fn, _curr_path) # type: ignore
550
+ else:
551
+ indexable[key] = transform_fn(_curr_path, indexable[key]) # type: ignore
552
+
553
+ @staticmethod
554
+ def _remove_sentinel(yaml_content: str) -> str:
555
+ '''Removes the root sentinel key from the given raw YAML string. Returns the modified YAML.'''
556
+ # Since the parser should put all root keys on their own line, remove (first) line containing the sentinel key
557
+ lines: list[str] = yaml_content.split('\n')
558
+ for index, line in enumerate(lines):
559
+ if line.strip() == f"{ SENTINEL_KEY }:":
560
+ lines.pop(index)
561
+ break
562
+ return '\n'.join(lines).strip('\n') + '\n'
563
+
564
+ def _dump_to_str(self, data: Any) -> str:
565
+ '''Since the YAML parser requires a stream object to dump to, this method handles text streaming.'''
566
+ builder = StringIO()
567
+ self._parser.dump(data, builder)
568
+ return builder.getvalue().strip('\n') + '\n'
569
+
570
+ # Comparing to older versions of this vault
571
+
572
+ def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str:
573
+ '''
574
+ Generates a diff for the edit mode Jinja2 YAML vault code (from `Vault.as_editable`) of a previous vault to this one's.
575
+ Set `context_lines` to specify how many lines of context are shown before and after the actual diff lines.
576
+ If `show_filenames` is set to True and the vaults are `VaultFile` objects,
577
+ the previous and current filenames will be shown in the diff header.
578
+ '''
579
+ # Generate filenames
580
+ prev_filename: str = 'Previous vault'
581
+ if show_filenames and isinstance(prev_vault, VaultFile):
582
+ prev_filename = prev_vault.vault_path
583
+ curr_filename: str = 'Current vault'
584
+ if show_filenames and isinstance(self, VaultFile):
585
+ curr_filename = self.vault_path
586
+ # Generate diff
587
+ return '\n'.join(
588
+ unified_diff(
589
+ prev_vault.as_editable().split('\n'),
590
+ self.as_editable().split('\n'),
591
+ fromfile = prev_filename,
592
+ tofile = curr_filename,
593
+ n = context_lines,
594
+ lineterm = ''
595
+ )
596
+ )
597
+
598
+ def changes(self, prev_vault: 'Vault') -> tuple[ChangeList, ChangeList, ChangeList, ChangeList]:
599
+ '''
600
+ Returns the changes between two (decrypted) vault root data structures.
601
+ Included changes are added, removed, changed and de-encrypted keys.
602
+ Changes are modeled as traversal paths and only minimal paths are recorded.
603
+ Returns a tuple of (decrypted_paths, removed_paths, changed_paths, added_paths)
604
+ where each element is a list of traversal paths.
605
+ Note that decrypted variables will be recorded twice: In decrypted_paths and in changed_paths.
606
+ '''
607
+ # Prepare input and result data
608
+ old_root: CommentedMap = prev_vault._data
609
+ new_root: CommentedMap = self._data
610
+ decrypted_paths: ChangeList = []
611
+ removed_paths : ChangeList = []
612
+ changed_paths : ChangeList = []
613
+ added_paths : ChangeList = []
614
+ # Traverse root and find changes
615
+ def _traverse_and_find_changes(path: tuple[Hashable, ...], old_node: Any, new_node: Any) -> None:
616
+ '''Traverses the old and new data structures and finds changes, discarding each branch after finding a minimal path.'''
617
+ # Type changed (possibly even decryption)
618
+ if type(old_node) is not type(new_node):
619
+ if type(old_node) is EncryptedVar:
620
+ decrypted_paths.append(path)
621
+ changed_paths.append(path)
622
+ return
623
+ # Leaf value changed (or abort because this is a leaf and it stayed the same)
624
+ if not isinstance(old_node, dict | list):
625
+ # We have to do a special comparison for EncryptedVars as their cipher contains a random salt and can't be compared
626
+ if type(old_node) is EncryptedVar:
627
+ if self.keyring.decrypt(old_node.cipher) != self.keyring.decrypt(new_node.cipher):
628
+ changed_paths.append(path)
629
+ elif old_node != new_node:
630
+ changed_paths.append(path)
631
+ return
632
+ # Check for added and removed nodes in indexables and recurse
633
+ if (is_dict := isinstance(old_node, dict)):
634
+ keys: list[Hashable] = sorted(set(old_node.keys()).union(new_node.keys()))
635
+ else:
636
+ keys: list[Hashable] = list(range(max(len(old_node), len(new_node))))
637
+ for key in keys:
638
+ _path: tuple[Hashable, ...] = path + ( key, )
639
+ # Check if a key has been added or removed
640
+ if (is_dict and key in old_node and key not in new_node) or \
641
+ (not is_dict and key < len(old_node) and key >= len(new_node)): # type: ignore
642
+ removed_paths.append(_path)
643
+ continue
644
+ if (is_dict and key in new_node and key not in old_node) or \
645
+ (not is_dict and key < len(new_node) and key >= len(old_node)): # type: ignore
646
+ added_paths.append(_path)
647
+ continue
648
+ # Traverse subtree of node
649
+ _traverse_and_find_changes(_path, old_node[key], new_node[key]) # type: ignore
650
+ _traverse_and_find_changes(tuple(), old_root, new_root)
651
+ return decrypted_paths, removed_paths, changed_paths, added_paths
652
+
653
+ def copy(self) -> 'Vault':
654
+ '''Create a copy of this `Vault` instance.'''
655
+ copy = Vault('', self.keyring)
656
+ copy._data = self._data.copy()
657
+ copy.full_encryption = self.full_encryption
658
+ copy.keyring = self.keyring
659
+ copy._parser = self._parser
660
+ return copy
661
+
662
+ # Search functions
663
+
664
+ def search_keys(self, query: str, is_regex: bool = False, as_bool: bool = False) -> bool | list[tuple[Hashable, ...]]:
665
+ '''
666
+ Matches query on all key names in the vault's data and returns matches or a flag signifying some matches were found.
667
+ Non-regex queries are case-insensitive.
668
+ '''
669
+ matches: list[tuple[Hashable, ...]] = []
670
+ query = str(query)
671
+ # Match on all keys by traversing through each leaf's path and looking for matches
672
+ def _match_keys(path: tuple[Hashable, ...], value: Any) -> Any:
673
+ '''Checks all keys in a leaf's path for query matches.'''
674
+ for curr_end, segment in enumerate(path, start=1):
675
+ segment = str(segment)
676
+ if (is_regex and re.match(query, segment)) or (not is_regex and segment.lower() == query.lower()):
677
+ matches.append(path[:curr_end])
678
+ return value
679
+ Vault._transform_leaves(self._data, _match_keys, tuple())
680
+ # Return bool or list of matches
681
+ return bool(matches) if as_bool else matches
682
+
683
+ def search_leaf_values(self, query: str, is_regex: bool = False, as_bool: bool = False) -> bool | list[tuple[Hashable, ...]]:
684
+ '''
685
+ Matches query on all (decrypted) leaf values in the vault's data and returns matches or a flag signifying some matches were found.
686
+ Non-regex queries are case-insensitive.
687
+ '''
688
+ matches: list[tuple[Hashable, ...]] = []
689
+ query = str(query)
690
+ # Match on all decrypted leaf values by traversing through each leaf's path and looking for matches
691
+ def _match_decrypted_leaves(path: tuple[Hashable, ...], value: Any) -> Any:
692
+ '''Checks the decrypted value of the current leaf for query matches.'''
693
+ if type(value) is EncryptedVar:
694
+ value = self.keyring.decrypt(value.cipher)
695
+ if value is None:
696
+ return value
697
+ value = str(value)
698
+ if (is_regex and re.match(query, value)) or (not is_regex and value.lower() == query.lower()):
699
+ matches.append(path)
700
+ return value
701
+ Vault._transform_leaves(self._data, _match_decrypted_leaves, tuple())
702
+ # Return bool or list of matches
703
+ return bool(matches) if as_bool else matches
704
+
705
+ def search_vaulttext(
706
+ self, query: str, is_regex: bool = False, as_bool: bool = False, from_plain: bool = False, multiline: bool = False
707
+ ) -> bool | list[MatchLocation]:
708
+ '''
709
+ Finds matches for the given query in the fully decrypted editable Jinja2 YAML vault code (from `Vault.as_editable`)
710
+ and returns the match locations as tuples of ((start_line, start_col), (end_line, end_col))
711
+ or a flag signifying some matches were found.
712
+ The locations are relative to the `as_editable` unless you set `from_plain`, in which case `as_plain` is used.
713
+ If `multiline` is set, a regex `.` matches newlines.
714
+ Non-regex queries are case-insensitive.
715
+ '''
716
+ yaml_content: str = self.as_plain() if from_plain else self.as_editable()
717
+ if not is_regex:
718
+ yaml_content = yaml_content.lower()
719
+ matches: list[MatchLocation] = []
720
+ # Ensure the query is properly escaped if it's plain text
721
+ pattern: str = query if is_regex else re.escape(query.lower())
722
+ # Process all matches
723
+ _matches = re.finditer(pattern, yaml_content, re.DOTALL) if multiline else re.finditer(pattern, yaml_content)
724
+ for match in _matches:
725
+ start_idx: int = match.start()
726
+ end_idx: int = match.end()
727
+ # Convert match indices to line and column numbers (1-indexed)
728
+ start_line: int = yaml_content[:start_idx].count('\n') + 1
729
+ start_col: int = start_idx - yaml_content[:start_idx].rfind('\n')
730
+ end_line: int = yaml_content[:end_idx].count('\n') + 1
731
+ end_col: int = end_idx - yaml_content[:end_idx].rfind('\n')
732
+ # Add tuple of converted indices to matches
733
+ matches.append(( ( start_line, start_col ), ( end_line, end_col ) ))
734
+ # Return bool or list of matches
735
+ return bool(matches) if as_bool else matches
736
+
737
+ # Internals
738
+
739
+ def __repr__(self) -> str:
740
+ return f"Vault({ 'un' * (not self.full_encryption) }encrypted)"
741
+
742
+ class VaultFile(Vault):
743
+ '''
744
+ Wrapper around a `Vault` that handles reading from and writing to a (Jinja2 yaml) vault file on disk.
745
+ Note that the file has to exist already at init or a `FileNotFoundError` error will be raised on loading and/or saving.
746
+ Use the `VaultFile.create` method to create a file that doesn't exist yet with your desired encryption settings.
747
+ '''
748
+
749
+ def __init__(self, vault_path: str, keyring: VaultKeyring | None = None) -> None:
750
+ '''
751
+ Loads the contents of the specified vault file and initializes a `Vault` with them.
752
+ If no keyring is supplied, only plain vars and content are supported.
753
+ '''
754
+ self.vault_path: str = os.path.abspath(vault_path)
755
+ yaml_content: str = self._load_file_content(self.vault_path)
756
+ super().__init__(yaml_content, keyring=keyring)
757
+
758
+ @classmethod
759
+ def create(
760
+ VaultFile: Type['VaultFile'],
761
+ path: str,
762
+ content: str = '',
763
+ full_encryption: bool = True,
764
+ permissions: octal | None = None,
765
+ keyring: VaultKeyring | None = None
766
+ ) -> 'VaultFile':
767
+ '''
768
+ Creates a new vault file at the specified path. Raises a `FileExistsError` if the path is already occupied.
769
+ If `content` is set, the vault will contain this Jinja2 yaml text instead of being empty.
770
+ If `full_encryption` is set to True, the file will be wholly encrypted, even if empty.
771
+ If `permissions` are set, the new file's permissions will be modified to that octal.
772
+ If no keyring is supplied, only plain vars are supported and enabling `full_encryption` will not work.
773
+ '''
774
+ if os.path.exists(path):
775
+ raise FileExistsError(f"Vault file { path } already exists")
776
+ # Create file first and set permissions before filling in the data
777
+ with open(path, 'w'): pass
778
+ if permissions is not None:
779
+ os.chmod(path, permissions)
780
+ # Write content to file
781
+ if full_encryption and not keyring:
782
+ raise NoVaultKeysError(f"No vault keys available to write encrypted content to { path }")
783
+ with open(path, 'w') as file:
784
+ file.write(keyring.encrypt(content) if full_encryption else content) # type: ignore
785
+ # Create VaultFile and write content to disk
786
+ vaultfile = VaultFile(path, keyring=keyring)
787
+ vaultfile.full_encryption = full_encryption
788
+ vaultfile.save()
789
+ return vaultfile
790
+
791
+ @classmethod
792
+ def from_editable(VaultFile: Type['VaultFile'], prev_vault_file: 'VaultFile', edited_content: str) -> 'VaultFile':
793
+ '''Converts a YAML vault edited from a `VaultFile.as_editable` template into a new `VaultFile`. Does not update the file on disk.'''
794
+ # Create vault from editable, then wrap with our class and copy relevant attributes over
795
+ vault: Vault = Vault.from_editable(prev_vault_file, edited_content)
796
+ vault.__class__ = VaultFile
797
+ vault.vault_path = prev_vault_file.vault_path # type: ignore
798
+ return vault # type: ignore
799
+
800
+ def save(self) -> None:
801
+ '''Saves the current `Vault` contents to the vault file attached to this `VaultFile`. '''
802
+ self._save_to_file(self.vault_path, self.as_encrypted())
803
+
804
+ def copy(self) -> 'VaultFile':
805
+ '''Create a copy of this `VaultFile` instance.'''
806
+ copy: VaultFile = self.from_editable(self, '')
807
+ copy._data = self._data.copy()
808
+ copy.full_encryption = self.full_encryption
809
+ copy.keyring = self.keyring
810
+ copy._parser = self._parser
811
+ return copy
812
+
813
+ @staticmethod
814
+ def _load_file_content(path: str) -> str:
815
+ '''Loads the contents of a file into a string. Throws an error if the file does not exist.'''
816
+ if not os.path.isfile(path):
817
+ raise FileNotFoundError(f"Vault file { path } could not be found")
818
+ with open(path, 'r') as file:
819
+ return file.read()
820
+
821
+ @staticmethod
822
+ def _save_to_file(path: str, content: str) -> None:
823
+ '''Saves the given content to a file. Throws an error if the file does not exist.'''
824
+ if not os.path.isfile(path):
825
+ raise FileNotFoundError(f"Vault file { path } could not be found")
826
+ with open(path, 'w') as file:
827
+ file.write(content)
828
+
829
+ def __repr__(self) -> str:
830
+ return f"VaultFile({ self.vault_path }, { 'un' * (not self.full_encryption) }encrypted)"