Add module_utils and filters for quoting and unquoting (#53)

* Move splitting code to own file.

* Move list to dictionary code to quoting as well.

* Add quoting functionality.

* Add quoting filters.

* Add integration tests to CI.

* Fix bugs, increase coverage.

* Make parsing more strict.

* Extract function parse_argument_value from split_routeros_command to make proper parsing of WHERE possible.

* Adjust expected error message in integration tests.

* Simplify code and improve coverage.

* Add changelog fragment for WHERE strictness in api module.

* Add documenation.

* Fix typo.

* Add documentation references.

* Add example to api module which uses quote_argument_value.

* Fix bug, and add tests which prevent this in the future.

* Add more escape sequence tests.

* Make sure all control characters are quoted.
This commit is contained in:
Felix Fontein 2021-10-11 23:44:40 +02:00 committed by GitHub
parent f9d246cd7a
commit d73eb1c144
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 803 additions and 145 deletions

View file

@ -119,6 +119,11 @@ options:
- See also I(validate_cert_hostname). Only used when I(tls=true) and I(validate_certs=true).
type: path
version_added: 1.2.0
seealso:
- ref: ansible_collections.community.routeros.docsite.api-guide
description: How to connect to RouterOS devices with the RouterOS API
- ref: ansible_collections.community.routeros.docsite.quoting
description: How to quote and unquote commands and arguments
'''
EXAMPLES = '''
@ -190,7 +195,10 @@ EXAMPLES = '''
password: "{{ password }}"
username: "{{ username }}"
path: "{{ path }}"
update: ".id={{ query_id }} address={{ ip3 }}"
update: >-
.id={{ query_id }}
address={{ ip3 }}
comment={{ 'A comment with spaces' | community.routeros.quote_argument_value }}
register: updateout
- name: Dump "Update" output
@ -258,8 +266,16 @@ message:
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.routeros.plugins.module_utils.quoting import (
ParseError,
convert_list_to_dictionary,
parse_argument_value,
split_routeros_command,
)
import re
import ssl
import traceback
@ -274,95 +290,6 @@ except Exception as e:
LIB_IMP_ERR = traceback.format_exc()
class ParseError(Exception):
pass
ESCAPE_SEQUENCES = {
b'"': b'"',
b'\\': b'\\',
b'?': b'?',
b'$': b'$',
b'_': b'_',
b'a': b'\a',
b'b': b'\b',
b'f': b'\xFF',
b'n': b'\n',
b'r': b'\r',
b't': b'\t',
b'v': b'\v',
}
ESCAPE_DIGITS = b'0123456789ABCDEF'
def split_routeros(line):
line = to_bytes(line)
result = []
current = []
index = 0
length = len(line)
# States:
# 0 = outside param
# 1 = param before '='
# 2 = param after '=' without quote
# 3 = param after '=' with quote
state = 0
while index < length:
ch = line[index:index + 1]
index += 1
if state == 0 and ch == b' ':
pass
elif state in (1, 2) and ch == b' ':
state = 0
result.append(b''.join(current))
current = []
elif ch == b'=' and state == 1:
state = 2
current.append(ch)
if index + 1 < length and line[index:index + 1] == b'"':
state = 3
index += 1
elif ch == b'"':
if state == 3:
state = 0
result.append(b''.join(current))
current = []
if index + 1 < length and line[index:index + 1] != b' ':
raise ParseError('Ending \'"\' must be followed by space or end of string')
else:
raise ParseError('\'"\' must follow \'=\'')
elif ch == b'\\':
if index + 1 == length:
raise ParseError('\'\\\' must not be at the end of the line')
ch = line[index:index + 1]
index += 1
if ch in ESCAPE_SEQUENCES:
current.append(ch)
else:
d1 = ESCAPE_DIGITS.find(ch)
if d1 < 0:
raise ParseError('Invalid escape sequence \'\\{0}\''.format(ch))
if index + 1 == length:
raise ParseError('Hex escape sequence cut off at end of line')
ch2 = line[index:index + 1]
d2 = ESCAPE_DIGITS.find(ch2)
index += 1
if d2 < 0:
raise ParseError('Invalid hex escape sequence \'\\{0}{1}\''.format(ch, ch2))
result.append(chr(d1 * 16 + d2))
else:
current.append(ch)
if state == 0:
state = 1
if state in (1, 2):
if current:
result.append(b''.join(current))
elif state == 3:
raise ParseError('Unexpected end of string during escaped parameter')
return [to_native(part) for part in result]
class ROS_api_module:
def __init__(self):
module_args = dict(
@ -410,7 +337,24 @@ class ROS_api_module:
self.where = None
self.query = self.module.params['query']
if self.query:
self.query = self.list_remove_empty(self.split_params(self.query))
where_index = self.query.find(' WHERE ')
if where_index < 0:
self.query = self.split_params(self.query)
else:
where = self.query[where_index + len(' WHERE '):]
self.query = self.split_params(self.query[:where_index])
# where must be of the format '<attribute> <operator> <value>'
m = re.match(r'^\s*([^ ]+)\s+([^ ]+)\s+(.*)$', where)
if not m:
self.errors("invalid syntax for 'WHERE %s'" % where)
try:
self.where = [
m.group(1), # attribute
m.group(2), # operator
parse_argument_value(m.group(3).rstrip())[0], # value
]
except ParseError as exc:
self.errors("invalid syntax for 'WHERE %s': %s" % (where, exc))
try:
idx = self.query.index('WHERE')
self.where = self.query[idx + 1:]
@ -439,26 +383,14 @@ class ROS_api_module:
else:
self.api_get_all()
def list_remove_empty(self, check_list):
while("" in check_list):
check_list.remove("")
return check_list
def list_to_dic(self, ldict):
dict = {}
for p in ldict:
if '=' not in p:
self.errors("missing '=' after '%s'" % p)
p = p.split('=', 1)
if p[1]:
dict[p[0]] = p[1]
return dict
return convert_list_to_dictionary(ldict, skip_empty_values=True, require_assignment=True)
def split_params(self, params):
if not isinstance(params, str):
raise AssertionError('Parameters can only be a string, received %s' % type(params))
try:
return split_routeros(params)
return split_routeros_command(params)
except ParseError as e:
self.module.fail_json(msg=to_native(e))
@ -512,11 +444,6 @@ class ROS_api_module:
keys[k] = Key(k)
try:
if self.where:
if len(self.where) < 3:
self.errors("invalid syntax for 'WHERE %s'"
% ' '.join(self.where))
where = []
if self.where[1] == '==':
select = self.api_path.select(*keys).where(keys[self.where[0]] == self.where[2])
elif self.where[1] == '!=':
@ -528,11 +455,10 @@ class ROS_api_module:
else:
self.errors("'%s' is not operator for 'where'"
% self.where[1])
for row in select:
self.result['message'].append(row)
else:
for row in self.api_path.select(*keys):
self.result['message'].append(row)
select = self.api_path.select(*keys)
for row in select:
self.result['message'].append(row)
if len(self.result['message']) < 1:
msg = "no results for '%s 'query' %s" % (' '.join(self.path),
' '.join(self.query))