ansible-vars 1.0.6__tar.gz → 1.0.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ansible-vars
3
- Version: 1.0.6
3
+ Version: 1.0.7
4
4
  Summary: Manage vaults and variable files for Ansible
5
5
  Project-URL: Homepage, https://github.com/xorwow/ansible-vars
6
6
  Project-URL: Issues, https://github.com/xorwow/ansible-vars/issues
@@ -254,21 +254,23 @@ When editing a file or creating a daemon, decrypted vaults are written to disk t
254
254
 
255
255
  ## Known issues and limitations
256
256
 
257
- - YAML round-trip parser
257
+ - YAML round-trip parser:
258
258
  - Trailing comments and Jinja2 blocks may be misaligned and a trailing newline may be inserted/removed when switching between folded (`|`, `>`) and non-foldes values.
259
259
  - The `set` and `del` commands may remove trailing comments and Jinja2 blocks.
260
260
  - Explicit start/end markers (`---`, `...`) are not preserved.
261
261
  - Supports lists, dictionaries, and scalar values.
262
262
  - Does not support custom YAML tags (`!tag`).
263
- - Ansible
263
+ - Ansible:
264
264
  - Ansible only directly supports encrypted string values (although you can work around this with the `from_yaml` filter).
265
265
  - Ansible-encrypted strings must include a newline between the envelope and the cipher.
266
266
  - Ansible vault and variable file roots must be a dictionary.
267
- - `grep` command
267
+ - `grep` command:
268
268
  - Will ignore files which cannot be parsed as an Ansible YAML file.
269
- - `file-daemon` command
269
+ - `file-daemon` command:
270
270
  - Changes to file metadata (permissions, ...) are not mirrored.
271
- - `ansible-vars` cannot operate on files which are not (Jinja2) YAML dictionaries.
271
+ - `ansible-vars` does not support files which are not (Jinja2) YAML dictionaries, except for limited support in these commands:
272
+ - `edit`, `view` (without `--json` support), `encrypt`, `decrypt`, `is-encrypted`
273
+ - When a vault is modified, all of its ciphers will change due to new salts, even if only one encrypted variable has been changed.
272
274
 
273
275
  ## Extension plans
274
276
 
@@ -232,21 +232,23 @@ When editing a file or creating a daemon, decrypted vaults are written to disk t
232
232
 
233
233
  ## Known issues and limitations
234
234
 
235
- - YAML round-trip parser
235
+ - YAML round-trip parser:
236
236
  - Trailing comments and Jinja2 blocks may be misaligned and a trailing newline may be inserted/removed when switching between folded (`|`, `>`) and non-foldes values.
237
237
  - The `set` and `del` commands may remove trailing comments and Jinja2 blocks.
238
238
  - Explicit start/end markers (`---`, `...`) are not preserved.
239
239
  - Supports lists, dictionaries, and scalar values.
240
240
  - Does not support custom YAML tags (`!tag`).
241
- - Ansible
241
+ - Ansible:
242
242
  - Ansible only directly supports encrypted string values (although you can work around this with the `from_yaml` filter).
243
243
  - Ansible-encrypted strings must include a newline between the envelope and the cipher.
244
244
  - Ansible vault and variable file roots must be a dictionary.
245
- - `grep` command
245
+ - `grep` command:
246
246
  - Will ignore files which cannot be parsed as an Ansible YAML file.
247
- - `file-daemon` command
247
+ - `file-daemon` command:
248
248
  - Changes to file metadata (permissions, ...) are not mirrored.
249
- - `ansible-vars` cannot operate on files which are not (Jinja2) YAML dictionaries.
249
+ - `ansible-vars` does not support files which are not (Jinja2) YAML dictionaries, except for limited support in these commands:
250
+ - `edit`, `view` (without `--json` support), `encrypt`, `decrypt`, `is-encrypted`
251
+ - When a vault is modified, all of its ciphers will change due to new salts, even if only one encrypted variable has been changed.
250
252
 
251
253
  ## Extension plans
252
254
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ansible-vars"
7
- version = "1.0.6"
7
+ version = "1.0.7"
8
8
  authors = [
9
9
  { name="xorwow", email="pip@xorwow.de" },
10
10
  ]
@@ -39,7 +39,7 @@ from .vault import VaultFile, EncryptedVar, ProtoEncryptedVar
39
39
  from .vault_crypt import VaultKey, VaultKeyring
40
40
  from .util import DiffFileLogger, VaultDaemon
41
41
  from .constants import Unset, MatchLocation, SENTINEL_KEY
42
- from .errors import YAMLFormatError
42
+ from .errors import YAMLFormatError, UnsupportedGenericFileOperation
43
43
 
44
44
  ## CLI argument parsing
45
45
 
@@ -253,11 +253,17 @@ log_args.add_argument('--logging-key', '-Q', type=str, metavar='<identifier>', h
253
253
  # Commands
254
254
  commands = args.add_subparsers(dest='command', metavar='<command>', required=True)
255
255
 
256
- cmd_keyring = commands.add_parser('keyring', help='show available vault keys and their passphrases', description=HELP['cmd_keyring'])
256
+ cmd_keyring = commands.add_parser(
257
+ 'keyring', help='show available vault keys and their passphrases', description=HELP['cmd_keyring'],
258
+ formatter_class=RawDescriptionHelpFormatter
259
+ )
257
260
  cmd_keyring.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault keys as JSON and nothing else')
258
261
  cmd_keyring.add_argument('--keys-only', '-o', action='store_false', dest='show_passphrases', help='show only the vault keys, not the passphrases')
259
262
 
260
- cmd_create = commands.add_parser('create', help='create a new vault', description=HELP['cmd_create'])
263
+ cmd_create = commands.add_parser(
264
+ 'create', help=f"create a new vault ({ 'hybrid/plain' if DEFAULT_CREATE_PLAIN else 'fully encrypted' } by default)",
265
+ description=HELP['cmd_create'], formatter_class=RawDescriptionHelpFormatter
266
+ )
261
267
  cmd_create.add_argument('vault_path', type=str, metavar='<vault path>', help='path to create a new vault at') \
262
268
  .completer = _prefixed_path_completer # type: ignore
263
269
  # Invert flag if the user wants plain mode by default
@@ -266,50 +272,74 @@ if DEFAULT_CREATE_PLAIN:
266
272
  else:
267
273
  cmd_create.add_argument('--plain', '-p', action='store_false', dest='encrypt_vault', help='create without full file encryption')
268
274
  cmd_create.add_argument('--make-parents', '-m', action='store_true', help='create all directories in the given path')
269
- cmd_mutex = cmd_create.add_mutually_exclusive_group()
270
- cmd_mutex.add_argument('--no-edit', '-n', action='store_false', dest='open_edit_mode', help='just create the file, don\'t open it for editing')
271
- cmd_mutex.add_argument(
275
+ create_mutex = cmd_create.add_mutually_exclusive_group()
276
+ create_mutex.add_argument('--no-edit', '-n', action='store_false', dest='open_edit_mode', help='just create the file, don\'t open it for editing')
277
+ create_mutex.add_argument(
272
278
  '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
273
279
  )
274
280
 
275
- cmd_edit = commands.add_parser('edit', help='edit a vault', description=HELP['cmd_edit'])
281
+ cmd_edit = commands.add_parser(
282
+ 'edit', help='edit a vault', description=HELP['cmd_edit'], formatter_class=RawDescriptionHelpFormatter
283
+ )
276
284
  cmd_edit.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to edit') \
277
285
  .completer = _prefixed_path_completer # type: ignore
278
286
  cmd_edit.add_argument(
279
287
  '--edit-command', '-e', type=str, default=DEFAULT_EDITOR, help=f"editor command to use (runs as <command> <some path>) (default: { DEFAULT_EDITOR })"
280
288
  )
281
289
 
282
- cmd_view = commands.add_parser('view', help='show the decrypted contents of a vault')
290
+ cmd_view = commands.add_parser(
291
+ 'view', help='show the decrypted contents of a vault', formatter_class=RawDescriptionHelpFormatter
292
+ )
283
293
  cmd_view.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to dump') \
284
294
  .completer = _prefixed_path_completer # type: ignore
285
295
  cmd_view.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the vault data as JSON and nothing else')
286
296
 
287
- cmd_info = commands.add_parser('info', help='show information about a vault\'s variables', description=HELP['cmd_info'])
297
+ cmd_info = commands.add_parser(
298
+ 'info', help='show information about a vault\'s variables', description=HELP['cmd_info'],
299
+ formatter_class=RawDescriptionHelpFormatter
300
+ )
288
301
  cmd_info.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to analyze') \
289
302
  .completer = _prefixed_path_completer # type: ignore
290
303
  cmd_info.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the information as JSON and nothing else')
291
304
 
292
- cmd_encrypt = commands.add_parser('encrypt', help='encrypt a file in-place or a string with the encryption key', description=HELP['cmd_encrypt'])
305
+ cmd_encrypt = commands.add_parser(
306
+ 'encrypt', help='encrypt a file in-place or a string with the encryption key', description=HELP['cmd_encrypt'],
307
+ formatter_class=RawDescriptionHelpFormatter
308
+ )
293
309
  cmd_encrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
294
310
  cmd_encrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to encrypt') \
295
311
  .completer = _prefixed_path_completer # type: ignore
312
+ cmd_encrypt.add_argument('--quiet', '-q', action='store_true', help='only output the encrypted value (ignored in file mode)')
296
313
 
297
- cmd_decrypt = commands.add_parser('decrypt', help='decrypt a file in-place or a string', description=HELP['cmd_decrypt'])
314
+ cmd_decrypt = commands.add_parser(
315
+ 'decrypt', help='decrypt a file in-place or a string', description=HELP['cmd_decrypt'],
316
+ formatter_class=RawDescriptionHelpFormatter
317
+ )
298
318
  cmd_decrypt.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
299
319
  cmd_decrypt.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to decrypt') \
300
320
  .completer = _prefixed_path_completer # type: ignore
321
+ cmd_decrypt.add_argument('--quiet', '-q', action='store_true', help='only output the decrypted value (ignored in file mode)')
301
322
 
302
- cmd_is_enc = commands.add_parser('is-encrypted', help='check if a file or string is vault-encrypted', description=HELP['cmd_is_enc'])
323
+ cmd_is_enc = commands.add_parser(
324
+ 'is-encrypted', help='check if a file or string is vault-encrypted', description=HELP['cmd_is_enc'],
325
+ formatter_class=RawDescriptionHelpFormatter
326
+ )
303
327
  cmd_is_enc.add_argument('target_type', type=str, choices=['file', 'string'], help='select if target is a file path or a string')
304
328
  cmd_is_enc.add_argument('target', type=str, metavar='<vault path | string>', help='path of vault or string value to check') \
305
329
  .completer = _prefixed_path_completer # type: ignore
306
330
  cmd_is_enc.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if encrypted or 100 if unencrypted')
307
331
 
308
- cmd_convert = commands.add_parser('convert', help='switch vault between outer (file) and inner (vars) encryption', description=HELP['cmd_convert'])
332
+ cmd_convert = commands.add_parser(
333
+ 'convert', help='switch vault between outer (file) and inner (vars) encryption', description=HELP['cmd_convert'],
334
+ formatter_class=RawDescriptionHelpFormatter
335
+ )
309
336
  cmd_convert.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to convert') \
310
337
  .completer = _prefixed_path_completer # type: ignore
311
338
 
312
- cmd_grep = commands.add_parser('grep', help='search a file or folder for a pattern', description=HELP['cmd_grep'])
339
+ cmd_grep = commands.add_parser(
340
+ 'grep', help='search a file or folder for a pattern', description=HELP['cmd_grep'],
341
+ formatter_class=RawDescriptionHelpFormatter
342
+ )
313
343
  cmd_grep.add_argument('query', type=str, metavar='<pattern>', help='regex query to match with targets')
314
344
  cmd_grep.add_argument('targets', type=str, nargs='+', metavar='[<target> ...]', help='file(s) or folder(s) to search recursively') \
315
345
  .completer = _prefixed_path_completer # type: ignore
@@ -324,21 +354,30 @@ grep_mutex_type.add_argument('--multiline', '-m', action='store_true', help='mak
324
354
  cmd_grep.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the matches as JSON and nothing else')
325
355
  cmd_grep.add_argument('--quiet', '-q', action='store_true', help='no output, only set the rc to 0 if any matches found or 100 if none found')
326
356
 
327
- cmd_diff = commands.add_parser('diff', help='show line differences between two vaults', description=HELP['cmd_diff'])
357
+ cmd_diff = commands.add_parser(
358
+ 'diff', help='show line differences between two vaults', description=HELP['cmd_diff'],
359
+ formatter_class=RawDescriptionHelpFormatter
360
+ )
328
361
  cmd_diff.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
329
362
  .completer = _prefixed_path_completer # type: ignore
330
363
  cmd_diff.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
331
364
  .completer = _prefixed_path_completer # type: ignore
332
365
  cmd_diff.add_argument('--context-lines', '-c', type=int, metavar='<amount>', default=3, help='show <amount> lines of context around changed lines (default: 3)')
333
366
 
334
- cmd_changes = commands.add_parser('changes', help='show var changes between vaults', description=HELP['cmd_changes'])
367
+ cmd_changes = commands.add_parser(
368
+ 'changes', help='show var changes between vaults', description=HELP['cmd_changes'],
369
+ formatter_class=RawDescriptionHelpFormatter
370
+ )
335
371
  cmd_changes.add_argument('old_vault', type=str, metavar='<old vault vault path>', help='path of "old"/base vault') \
336
372
  .completer = _prefixed_path_completer # type: ignore
337
373
  cmd_changes.add_argument('new_vault', type=str, metavar='<new vault vault path>', help='path of "new"/changed vault') \
338
374
  .completer = _prefixed_path_completer # type: ignore
339
375
  cmd_changes.add_argument('--json', '-j', action='store_true', dest='as_json', help='print added/changed/removed/decrypted vars as JSON and nothing else')
340
376
 
341
- cmd_daemon = commands.add_parser('file-daemon', help='sync decrypted vault copies into a folder', description=HELP['cmd_daemon'])
377
+ cmd_daemon = commands.add_parser(
378
+ 'file-daemon', help='sync decrypted vault copies into a folder', description=HELP['cmd_daemon'],
379
+ formatter_class=RawDescriptionHelpFormatter
380
+ )
342
381
  cmd_daemon.add_argument('target_root', type=str, metavar='<target root path>', help='root folder the decrypted files and folders should be synced into (non-existent or empty)') \
343
382
  .completer = _prefixed_path_completer # type: ignore
344
383
  # This arg can be repeated (results in [ [source, rel_target], ... ])
@@ -350,7 +389,10 @@ cmd_daemon.add_argument('--no-recurse', '-n', action='store_false', dest='recurs
350
389
  cmd_daemon.add_argument('--no-default-dirs', '-N', action='store_false', dest='include_default_dirs', help='don\'t include default sync sources')
351
390
  cmd_daemon.add_argument('--force', '-f', action='store_true', help='if the target root already exists and is not empty, delete its contents')
352
391
 
353
- cmd_get = commands.add_parser('get', help='get a key\'s (decrypted) value if it exists', description=HELP['cmd_get'])
392
+ cmd_get = commands.add_parser(
393
+ 'get', help='get a key\'s (decrypted) value if it exists', description=HELP['cmd_get'],
394
+ formatter_class=RawDescriptionHelpFormatter
395
+ )
354
396
  cmd_get.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to get value from') \
355
397
  .completer = _prefixed_path_completer # type: ignore
356
398
  cmd_get.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
@@ -359,14 +401,20 @@ get_mutex_format = cmd_get.add_mutually_exclusive_group()
359
401
  get_mutex_format.add_argument('--quiet', '-q', action='store_true', help='only output the raw YAML value or set the rc to 100 if the key doesn\'t exist')
360
402
  get_mutex_format.add_argument('--json', '-j', action='store_true', dest='as_json', help='print the value as JSON or set the rc to 100 if the key doesn\'t exist')
361
403
 
362
- cmd_set = commands.add_parser('set', help='update a key\'s value or add a new key (experimental!)', description=HELP['cmd_set'])
404
+ cmd_set = commands.add_parser(
405
+ 'set', help='update a key\'s value or add a new key (experimental!)', description=HELP['cmd_set'],
406
+ formatter_class=RawDescriptionHelpFormatter
407
+ )
363
408
  cmd_set.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to set value in') \
364
409
  .completer = _prefixed_path_completer # type: ignore
365
410
  cmd_set.add_argument('value', type=str, metavar='<value>', help='value to set (will be loaded as YAML)')
366
411
  cmd_set.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
367
412
  cmd_set.add_argument('--encrypt', '-e', action='store_true', dest='encrypt_value', help='encrypt the value if it is\'t encrypted yet')
368
413
 
369
- cmd_del = commands.add_parser('del', help='delete a key and its value if they exist (experimental!)', description=HELP['cmd_del'])
414
+ cmd_del = commands.add_parser(
415
+ 'del', help='delete a key and its value if they exist (experimental!)', description=HELP['cmd_del'],
416
+ formatter_class=RawDescriptionHelpFormatter
417
+ )
370
418
  cmd_del.add_argument('vault_path', type=str, metavar='<vault path>', help='path of vault to delete key from') \
371
419
  .completer = _prefixed_path_completer # type: ignore
372
420
  cmd_del.add_argument('key_segments', type=str, nargs='+', metavar='<key segment> [<key segment> ...]', help='segment(s) of the key to look up (`[<num>]` for numbers)')
@@ -582,7 +630,30 @@ if config.command in [ 'create', 'edit' ]:
582
630
  vault = VaultFile.create(vault_path, full_encryption=config.encrypt_vault, permissions=0o600, keyring=keyring)
583
631
  print(f"Created { 'encrypted' if vault.full_encryption else 'plain' } vault at { vault_path }", Color.GOOD)
584
632
  else:
585
- vault = VaultFile(vault_path, keyring=keyring)
633
+ try:
634
+ vault = VaultFile(vault_path, keyring=keyring)
635
+ except YAMLFormatError:
636
+ print('Invalid vault format, will be treated as a generic file', Color.MEH)
637
+ with NamedTemporaryFile(mode='w+', dir=config.temp_dir, prefix='vaultlike_') as edit_file:
638
+ with open(vault_path, 'r+') as file:
639
+ # Load and decrypt file
640
+ content: str = file.read()
641
+ if (is_enc := VaultKey.is_encrypted(content)):
642
+ content = keyring.decrypt(content)
643
+ # Let user edit the content in a temporary file
644
+ edit_file.write(content)
645
+ edit_file.flush()
646
+ sys_command(f"{ config.edit_command } { edit_file.name }", shell=True)
647
+ edit_file.seek(0)
648
+ # Encrypt the new content and write it back
649
+ new_content: str = edit_file.read()
650
+ if is_enc:
651
+ new_content = keyring.encrypt(new_content)
652
+ file.seek(0)
653
+ file.truncate()
654
+ file.write(new_content)
655
+ print(f"Saved changes!", Color.GOOD)
656
+ exit()
586
657
  # Open vault for edit mode
587
658
  if getattr(config, 'open_edit_mode', True):
588
659
  print(f"Editing vault at { vault_path }")
@@ -591,6 +662,7 @@ if config.command in [ 'create', 'edit' ]:
591
662
  # Write vault contents to temp file
592
663
  editable: str = vault.as_editable()
593
664
  edit_file.write(editable)
665
+ edit_file.flush()
594
666
  while True:
595
667
  # Open editor and wait for it to close
596
668
  edit_file.seek(0)
@@ -613,18 +685,23 @@ if config.command in [ 'create', 'edit' ]:
613
685
  break
614
686
  new_vault.save()
615
687
  print(f"Saved changes!", Color.GOOD)
616
- # Warn about decrypted variables
617
- if (changes := new_vault.changes(vault))[0]:
618
- print(f"\n[!] The following vars have been decrypted in this edit:", Color.MEH)
619
- print('\n'.join([ f"- { format_key_path(path) }" for path in changes[0] ]))
620
- # Inform about new plain leaf variables
688
+ decrypted_vars: list[tuple[Hashable, ...]] = []
621
689
  new_plain_leaves: list[tuple[Hashable, ...]] = []
622
690
  def _find_new_plain_vars(path: tuple[Hashable, ...], value: Any) -> Any:
623
691
  if path != ( SENTINEL_KEY, ) and type(value) is not EncryptedVar:
624
- if vault.get(path, default=Unset) != value:
625
- new_plain_leaves.append(path)
692
+ if (old_value := vault.get(path, default=Unset)) != value:
693
+ std_print(path[-1], type(old_value), type(value))
694
+ if type(old_value) is EncryptedVar:
695
+ decrypted_vars.append(path)
696
+ else:
697
+ new_plain_leaves.append(path)
626
698
  return value
627
699
  vault._transform_leaves(new_vault._data, _find_new_plain_vars, tuple())
700
+ # Warn about decrypted variables
701
+ if decrypted_vars:
702
+ print(f"\n[!] The following vars have been decrypted in this edit:", Color.MEH)
703
+ print('\n'.join([ f"- { format_key_path(path) }" for path in decrypted_vars ]))
704
+ # Warn about new plain leaf variables
628
705
  if not new_vault.full_encryption and new_plain_leaves:
629
706
  print(f"\n[!] The following plain vars have been added in this edit:", Color.MEH)
630
707
  print('\n'.join([ f"- { format_key_path(path) }" for path in new_plain_leaves ]))
@@ -639,11 +716,18 @@ if config.command in [ 'create', 'edit' ]:
639
716
 
640
717
  if config.command == 'view':
641
718
  vault_path: str = resolve_vault_path(config.vault_path)
642
- vault = VaultFile(vault_path, keyring=keyring)
643
- if config.as_json:
644
- print_json(vault.as_json())
645
- else:
646
- print_yaml(vault.as_plain())
719
+ try:
720
+ vault = VaultFile(vault_path, keyring=keyring)
721
+ if config.as_json:
722
+ print_json(vault.as_json())
723
+ else:
724
+ print_yaml(vault.as_plain())
725
+ except YAMLFormatError:
726
+ if config.as_json:
727
+ raise UnsupportedGenericFileOperation(operation='--json')
728
+ with open(vault_path) as file:
729
+ content: str = file.read()
730
+ print(keyring.decrypt(content) if VaultKey.is_encrypted(content) else content)
647
731
 
648
732
  # Info command
649
733
 
@@ -684,27 +768,53 @@ if config.command in [ 'encrypt', 'decrypt', 'is-encrypted' ]:
684
768
  # File target
685
769
  if config.target_type == 'file':
686
770
  vault_path: str = resolve_vault_path(config.target)
687
- vault = VaultFile(vault_path, keyring=keyring)
771
+ is_generic: bool = False
772
+ try:
773
+ vault = VaultFile(vault_path, keyring=keyring)
774
+ is_enc: bool = vault.full_encryption
775
+ except YAMLFormatError as e:
776
+ print('Invalid vault format, will be treated as a generic file', Color.MEH)
777
+ with open(vault_path) as file:
778
+ is_enc = VaultKey.is_encrypted(file.read())
779
+ is_generic = True
688
780
  if config.command in [ 'encrypt', 'decrypt' ]:
689
- if vault.full_encryption == (config.command == 'encrypt'):
690
- print(f"Vault is already { 'en' if vault.full_encryption else 'de' }crypted.")
781
+ if is_enc == (config.command == 'encrypt'):
782
+ print(f"Vault is already { 'en' if is_enc else 'de' }crypted.", Color.GOOD)
691
783
  else:
692
- vault.full_encryption = (config.command == 'encrypt')
693
- vault.save()
694
- print(f"Vault { 'en' if vault.full_encryption else 'de' }crypted.", Color.GOOD)
784
+ is_enc = (config.command == 'encrypt')
785
+ # Generic file
786
+ if is_generic:
787
+ with open(vault_path, 'r+') as file:
788
+ content: str = file.read()
789
+ file.seek(0)
790
+ file.truncate()
791
+ file.write(keyring.encrypt(content) if is_enc else keyring.decrypt(content))
792
+ # Vault file
793
+ else:
794
+ vault.full_encryption = is_enc # type: ignore
795
+ vault.save() # type: ignore
796
+ print(f"Vault { 'en' if is_enc else 'de' }crypted.", Color.GOOD)
695
797
  else:
696
798
  if config.quiet:
697
- exit(0 if vault.full_encryption else 100)
799
+ exit(0 if is_enc else 100)
698
800
  else:
699
- print(f"Vault is { 'encrypted' if vault.full_encryption else 'plain or hybrid' }.", Color.GOOD if vault.full_encryption else Color.MEH)
801
+ print(f"Vault is { 'fully encrypted' if is_enc else 'plain or hybrid' }.", Color.GOOD if is_enc else Color.MEH)
700
802
  # String target
701
803
  else:
702
804
  is_encrypted: bool = VaultKey.is_encrypted(config.target)
805
+ # The key may not be passed properly, in which case we auto-convert literal '\n' to newlines
806
+ # We can assume an encrypted value should not contain any literal backslashes
807
+ if is_encrypted:
808
+ config.target = config.target.replace('\\n', '\n')
703
809
  if config.command in [ 'encrypt', 'decrypt' ]:
704
810
  if is_encrypted == (config.command == 'encrypt'):
705
- print(f"Value is already { 'en' if is_encrypted else 'de' }crypted.")
811
+ if not config.quiet:
812
+ print(f"Value is already { 'en' if is_encrypted else 'de' }crypted.", Color.GOOD)
813
+ else:
814
+ print(config.target)
706
815
  else:
707
- print(f"{ 'En' if not is_encrypted else 'De' }crypted value:", Color.GOOD)
816
+ if not config.quiet:
817
+ print(f"{ 'En' if not is_encrypted else 'De' }crypted value:", Color.GOOD)
708
818
  print(keyring.encrypt(config.target) if (config.command == 'encrypt') else keyring.decrypt(config.target))
709
819
  else:
710
820
  if config.quiet:
@@ -732,7 +842,7 @@ if config.command == 'convert':
732
842
  vault.save()
733
843
  print(f"Vault converted to { 'outer' if vault.full_encryption else 'inner' } encryption.", Color.GOOD)
734
844
  if not vault.full_encryption:
735
- print('Please check the vault to make sure all secrets have been encrypted', Color.MEH)
845
+ print('Please check the vault to make sure all secrets have been encrypted!', Color.MEH)
736
846
  _convert()
737
847
 
738
848
  # Grep command
@@ -2,6 +2,14 @@
2
2
 
3
3
  # YAML parsing
4
4
 
5
+ class UnsupportedGenericFileOperation(Exception):
6
+ '''The requested operation or flag is not available for generic (i.e. non-YAML-dictionary) files.'''
7
+
8
+ def __init__(self, *args: object, operation: str | None = None) -> None:
9
+ super().__init__(
10
+ f"The requested operation or flag is not available for generic files{ f': { operation }' * bool(operation) }", *args
11
+ )
12
+
5
13
  class YAMLFormatError(Exception):
6
14
  '''The supplied content is not a valid Ansible YAML file. Supports passing the triggering parent exception.'''
7
15
 
@@ -282,7 +282,7 @@ class VaultDaemon(FileSystemEventHandler):
282
282
  content: str = src.read()
283
283
  with open(target_path, 'w') as tgt:
284
284
  try:
285
- tgt.write(Vault(content, keyring=self.keyring).as_editable(with_header=False))
285
+ tgt.write(Vault(content, keyring=self.keyring).as_plain())
286
286
  self.debug(f"Created/Updated file { target_path } from vault contents")
287
287
  except:
288
288
  tgt.write(content)
@@ -6,16 +6,14 @@ from io import StringIO
6
6
  from functools import reduce
7
7
  from typing import Type, Hashable, Callable, Any
8
8
  from types import MappingProxyType
9
- from collections import OrderedDict
10
9
  from difflib import unified_diff
11
10
 
12
11
  # External library imports
13
12
  from ruamel.yaml import YAML
14
13
  from ruamel.yaml.nodes import ScalarNode
15
- from ruamel.yaml.parser import CommentToken
16
14
  from ruamel.yaml.representer import Representer
17
15
  from ruamel.yaml.constructor import Constructor
18
- from ruamel.yaml.comments import CommentedMap, CommentedSeq
16
+ from ruamel.yaml.comments import CommentedMap
19
17
 
20
18
  # Internal module imports
21
19
  from .constants import ThrowError, octal, Indexable, ChangeList, MatchLocation, SENTINEL_KEY, EDIT_MODE_HEADER, ENCRYPTED_VAR_TAG
@@ -53,7 +51,7 @@ class EncryptedVar():
53
51
  cipher: Any = constructor.construct_scalar(node)
54
52
  if not isinstance(cipher, str):
55
53
  raise TypeError(f"Expected encrypted value to be a str, but got { type(cipher) }")
56
- return EncryptedVar(str(cipher), name=node.id)
54
+ return EncryptedVar(cipher, name=node.id)
57
55
 
58
56
  class ProtoEncryptedVar():
59
57
  '''A variable marked to be encrypted in a `Vault` editable.'''
@@ -77,14 +75,14 @@ class ProtoEncryptedVar():
77
75
 
78
76
  @classmethod
79
77
  def to_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], representer: Representer, var: 'ProtoEncryptedVar') -> Any:
80
- return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style='\'')
78
+ return representer.represent_scalar(ENCRYPTED_VAR_TAG, var.plaintext, style=('|' if '\n' in var.plaintext else ''))
81
79
 
82
80
  @classmethod
83
81
  def from_yaml(ProtoEncryptedVar: Type['ProtoEncryptedVar'], constructor: Constructor, node: ScalarNode) -> 'ProtoEncryptedVar':
84
82
  plaintext: Any = constructor.construct_scalar(node)
85
83
  if not isinstance(plaintext, str):
86
84
  raise TypeError(f"Expected decrypted value to be a str, but got { type(plaintext) }")
87
- return ProtoEncryptedVar(str(plaintext).rstrip('\n'), name=node.id)
85
+ return ProtoEncryptedVar(plaintext.rstrip('\n'), name=node.id)
88
86
 
89
87
  class Vault():
90
88
  '''
@@ -205,7 +203,7 @@ class Vault():
205
203
  A read-only dictionary of the vault's variables, with any EncryptedVars already decrypted.
206
204
  Note that the state is frozen whenever you access this property, and not updated when the vault changes.
207
205
  '''
208
- copy: dict = self._data.copy()
206
+ copy: dict = Vault._copy_data(self._data)
209
207
  copy.pop(SENTINEL_KEY, None)
210
208
  def _traverse_and_decrypt(root: Any) -> Any:
211
209
  if isinstance(root, dict):
@@ -495,7 +493,7 @@ class Vault():
495
493
 
496
494
  def as_plain(self) -> str:
497
495
  '''Returns the vault in fully decrypted form as Jinja2 YAML code with the original metadata.'''
498
- copy: CommentedMap = self._data.copy()
496
+ copy: CommentedMap = Vault._copy_data(self._data)
499
497
  #copy.pop(SENTINEL_KEY, None) # <-- would break a file containing only metadata and the sentinel key
500
498
  def _decrypt_leaf(_: tuple[Hashable, ...], value: Any) -> Any:
501
499
  '''Transforms EncryptedVar leaves into decrypted strings.'''
@@ -510,7 +508,7 @@ class Vault():
510
508
  Returns the vault as Jinja2 YAML code with the original metadata.
511
509
  It is prepared for editing and later re-encryption with YAML tags and a static explanatory header.
512
510
  '''
513
- copy: CommentedMap = self._data.copy()
511
+ copy: CommentedMap = Vault._copy_data(self._data)
514
512
  def _convert_to_proto(path: tuple[Hashable, ...], value: Any) -> Any:
515
513
  '''Marks encrypted leaves for encryption after editing.'''
516
514
  if type(value) is not EncryptedVar:
@@ -526,7 +524,7 @@ class Vault():
526
524
 
527
525
  def as_encrypted(self) -> str:
528
526
  '''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()
527
+ copy: CommentedMap = Vault._copy_data(self._data)
530
528
  yaml_content: str = self._dump_to_str(copy)
531
529
  yaml_content = Vault._remove_sentinel(yaml_content)
532
530
  if self.full_encryption:
@@ -567,6 +565,23 @@ class Vault():
567
565
  self._parser.dump(data, builder)
568
566
  return builder.getvalue().strip('\n') + '\n'
569
567
 
568
+ @staticmethod
569
+ def _copy_data(data: Any) -> Any:
570
+ '''
571
+ Create a deep copy of any data, using the object's `copy()` method for dicts and lists.
572
+ Parser dicts and lists contain special data, and their copy function will preserve that.
573
+ The built-in `copy.deepcopy()` would require a `__deepcopy__` method for this to work.
574
+ Copying works for dicts and lists. Everything else is referenced normally.
575
+ Not thread-safe.
576
+ '''
577
+ if (is_dict := isinstance(data, dict)) or isinstance(data, list):
578
+ copy = data.copy()
579
+ keys = copy.keys() if is_dict else range(len(copy)) # type: ignore
580
+ for key in keys:
581
+ copy[key] = Vault._copy_data(copy[key])
582
+ return copy
583
+ return data
584
+
570
585
  # Comparing to older versions of this vault
571
586
 
572
587
  def diff(self, prev_vault: 'Vault', context_lines: int = 3, show_filenames: bool = True) -> str:
@@ -653,7 +668,7 @@ class Vault():
653
668
  def copy(self) -> 'Vault':
654
669
  '''Create a copy of this `Vault` instance.'''
655
670
  copy = Vault('', self.keyring)
656
- copy._data = self._data.copy()
671
+ copy._data = Vault._copy_data(self._data)
657
672
  copy.full_encryption = self.full_encryption
658
673
  copy.keyring = self.keyring
659
674
  copy._parser = self._parser
@@ -804,7 +819,7 @@ class VaultFile(Vault):
804
819
  def copy(self) -> 'VaultFile':
805
820
  '''Create a copy of this `VaultFile` instance.'''
806
821
  copy: VaultFile = self.from_editable(self, '')
807
- copy._data = self._data.copy()
822
+ copy._data = VaultFile._copy_data(self._data)
808
823
  copy.full_encryption = self.full_encryption
809
824
  copy.keyring = self.keyring
810
825
  copy._parser = self._parser
@@ -70,7 +70,7 @@ class VaultKey():
70
70
  vault_cipher = VaultKey._strip_vault_tag(vault_cipher)
71
71
  try:
72
72
  decrypted: bytes = self._vaultlib.decrypt(vault_cipher)
73
- return decrypted.decode('utf-8').strip()
73
+ return decrypted.decode('utf-8')
74
74
  except AnsibleVaultError as e:
75
75
  if e.message.startswith('Decryption failed (no vault secrets were found that could decrypt)'):
76
76
  raise VaultKeyMatchError(f"Could not match cipher with { self }")
File without changes
File without changes