Skip to content

Commit

Permalink
Properly JSON encode AnsibleUnsafe, using a pre-processor (#60602)
Browse files Browse the repository at this point in the history
* Properly JSON encode AnsibleUnsafe, using a pre-processor. Fixes #47295

* Add AnsibleUnsafe json tests

* Require preprocess_unsafe to be enabled for that functionality

* Support older json

* sort keys in tests

* Decouple AnsibleJSONEncoder from isinstance checks in preparation to move to module_utils

* Move AnsibleJSONEncoder to module_utils, consolidate instances

* add missing boilerplate

* remove removed.py from ignore
  • Loading branch information
sivel committed Aug 26, 2019
1 parent 1d405fd commit 5941e4c
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 63 deletions.
2 changes: 1 addition & 1 deletion lib/ansible/cli/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def dump(stuff):
else:
import json
from ansible.parsing.ajson import AnsibleJSONEncoder
results = json.dumps(stuff, cls=AnsibleJSONEncoder, sort_keys=True, indent=4)
results = json.dumps(stuff, cls=AnsibleJSONEncoder, sort_keys=True, indent=4, preprocess_unsafe=True)

return results

Expand Down
70 changes: 70 additions & 0 deletions lib/ansible/module_utils/common/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019 Ansible Project
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import json

import datetime

from ansible.module_utils._text import to_text
from ansible.module_utils.common._collections_compat import Mapping
from ansible.module_utils.common.collections import is_sequence


def _preprocess_unsafe_encode(value):
"""Recursively preprocess a data structure converting instances of ``AnsibleUnsafe``
into their JSON dict representations
Used in ``AnsibleJSONEncoder.iterencode``
"""
if getattr(value, '__UNSAFE__', False) and not getattr(value, '__ENCRYPTED__', False):
value = {'__ansible_unsafe': to_text(value, errors='surrogate_or_strict', nonstring='strict')}
elif is_sequence(value):
value = [_preprocess_unsafe_encode(v) for v in value]
elif isinstance(value, Mapping):
value = dict((k, _preprocess_unsafe_encode(v)) for k, v in value.items())

return value


class AnsibleJSONEncoder(json.JSONEncoder):
'''
Simple encoder class to deal with JSON encoding of Ansible internal types
'''

def __init__(self, preprocess_unsafe=False, **kwargs):
self._preprocess_unsafe = preprocess_unsafe
super(AnsibleJSONEncoder, self).__init__(**kwargs)

# NOTE: ALWAYS inform AWS/Tower when new items get added as they consume them downstream via a callback
def default(self, o):
if getattr(o, '__ENCRYPTED__', False):
# vault object
value = {'__ansible_vault': to_text(o._ciphertext, errors='surrogate_or_strict', nonstring='strict')}
elif getattr(o, '__UNSAFE__', False):
# unsafe object, this will never be triggered, see ``AnsibleJSONEncoder.iterencode``
value = {'__ansible_unsafe': to_text(o, errors='surrogate_or_strict', nonstring='strict')}
elif isinstance(o, Mapping):
# hostvars and other objects
value = dict(o)
elif isinstance(o, (datetime.date, datetime.datetime)):
# date object
value = o.isoformat()
else:
# use default encoder
value = super(AnsibleJSONEncoder, self).default(o)
return value

def iterencode(self, o, **kwargs):
"""Custom iterencode, primarily design to handle encoding ``AnsibleUnsafe``
as the ``AnsibleUnsafe`` subclasses inherit from string types and
``json.JSONEncoder`` does not support custom encoders for string types
"""
if self._preprocess_unsafe:
o = _preprocess_unsafe_encode(o)

return super(AnsibleJSONEncoder, self).iterencode(o, **kwargs)
4 changes: 4 additions & 0 deletions lib/ansible/module_utils/common/removed.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Copyright (c) 2018, Ansible Project
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

import json
import sys

Expand Down
29 changes: 1 addition & 28 deletions lib/ansible/module_utils/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,10 @@
import struct
import traceback
import uuid
from datetime import date, datetime

from functools import partial
from ansible.module_utils._text import to_bytes, to_text
from ansible.module_utils.common._collections_compat import Mapping
from ansible.module_utils.common.json import AnsibleJSONEncoder
from ansible.module_utils.six import iteritems
from ansible.module_utils.six.moves import cPickle

Expand Down Expand Up @@ -202,29 +201,3 @@ def send(self, data):
sf.close()

return to_text(response, errors='surrogate_or_strict')


# NOTE: This is a modified copy of the class in parsing.ajson to get around not
# being able to import that directly, nor some of the type classes
class AnsibleJSONEncoder(json.JSONEncoder):
'''
Simple encoder class to deal with JSON encoding of Ansible internal types
'''

def default(self, o):
if type(o).__name__ == 'AnsibleVaultEncryptedUnicode':
# vault object
value = {'__ansible_vault': to_text(o._ciphertext, errors='surrogate_or_strict', nonstring='strict')}
elif type(o).__name__ == 'AnsibleUnsafe':
# unsafe object
value = {'__ansible_unsafe': to_text(o, errors='surrogate_or_strict', nonstring='strict')}
elif isinstance(o, Mapping):
# hostvars and other objects
value = dict(o)
elif isinstance(o, (date, datetime)):
# date object
value = o.isoformat()
else:
# use default encoder
value = super(AnsibleJSONEncoder, self).default(o)
return value
37 changes: 5 additions & 32 deletions lib/ansible/parsing/ajson.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@

import json

from datetime import date, datetime
# Imported for backwards compat
from ansible.module_utils.common.json import AnsibleJSONEncoder

from ansible.module_utils._text import to_text
from ansible.module_utils.common._collections_compat import Mapping
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
from ansible.utils.unsafe_proxy import AnsibleUnsafe, wrap_var
from ansible.parsing.vault import VaultLib
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
from ansible.utils.unsafe_proxy import wrap_var


class AnsibleJSONDecoder(json.JSONDecoder):
Expand All @@ -38,32 +37,6 @@ def object_hook(self, pairs):
value.vault = self._vaults['default']
return value
elif key == '__ansible_unsafe':
return wrap_var(value.get('__ansible_unsafe'))
return wrap_var(value)

return pairs


# TODO: find way to integrate with the encoding modules do in module_utils
class AnsibleJSONEncoder(json.JSONEncoder):
'''
Simple encoder class to deal with JSON encoding of Ansible internal types
'''

# NOTE: ALWAYS inform AWS/Tower when new items get added as they consume them downstream via a callback
def default(self, o):
if isinstance(o, AnsibleVaultEncryptedUnicode):
# vault object
value = {'__ansible_vault': to_text(o._ciphertext, errors='surrogate_or_strict', nonstring='strict')}
elif isinstance(o, AnsibleUnsafe):
# unsafe object
value = {'__ansible_unsafe': to_text(o, errors='surrogate_or_strict', nonstring='strict')}
elif isinstance(o, Mapping):
# hostvars and other objects
value = dict(o)
elif isinstance(o, (date, datetime)):
# date object
value = o.isoformat()
else:
# use default encoder
value = super(AnsibleJSONEncoder, self).default(o)
return value
2 changes: 0 additions & 2 deletions test/sanity/ignore.txt
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,6 @@ lib/ansible/module_utils/cloud.py future-import-boilerplate
lib/ansible/module_utils/cloud.py metaclass-boilerplate
lib/ansible/module_utils/common/network.py future-import-boilerplate
lib/ansible/module_utils/common/network.py metaclass-boilerplate
lib/ansible/module_utils/common/removed.py future-import-boilerplate
lib/ansible/module_utils/common/removed.py metaclass-boilerplate
lib/ansible/module_utils/compat/ipaddress.py future-import-boilerplate
lib/ansible/module_utils/compat/ipaddress.py metaclass-boilerplate
lib/ansible/module_utils/compat/ipaddress.py no-assert
Expand Down
15 changes: 15 additions & 0 deletions test/units/parsing/test_ajson.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ansible.module_utils.common._collections_compat import Mapping
from ansible.parsing.ajson import AnsibleJSONEncoder, AnsibleJSONDecoder
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
from ansible.utils.unsafe_proxy import AnsibleUnsafeText


def test_AnsibleJSONDecoder_vault():
Expand All @@ -27,6 +28,20 @@ def test_AnsibleJSONDecoder_vault():
assert isinstance(data['foo']['password'], AnsibleVaultEncryptedUnicode)


def test_encode_decode_unsafe():
data = {
'key_value': AnsibleUnsafeText(u'{#NOTACOMMENT#}'),
'list': [AnsibleUnsafeText(u'{#NOTACOMMENT#}')],
'list_dict': [{'key_value': AnsibleUnsafeText(u'{#NOTACOMMENT#}')}]}
json_expected = (
'{"key_value": {"__ansible_unsafe": "{#NOTACOMMENT#}"}, '
'"list": [{"__ansible_unsafe": "{#NOTACOMMENT#}"}], '
'"list_dict": [{"key_value": {"__ansible_unsafe": "{#NOTACOMMENT#}"}}]}'
)
assert json.dumps(data, cls=AnsibleJSONEncoder, preprocess_unsafe=True, sort_keys=True) == json_expected
assert json.loads(json_expected, cls=AnsibleJSONDecoder) == data


def vault_data():
"""
Prepare AnsibleVaultEncryptedUnicode test data for AnsibleJSONEncoder.default().
Expand Down

0 comments on commit 5941e4c

Please sign in to comment.