diff --git a/changelogs/fragments/random_string_avoid.yml b/changelogs/fragments/random_string_avoid.yml new file mode 100644 index 0000000000..60eaf397d8 --- /dev/null +++ b/changelogs/fragments/random_string_avoid.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - random_string lookup plugin - allow user to set ``avoid_special_first`` and ``avoid_special_last`` (https://github.com/ansible-collections/community.general/issues/11816, https://github.com/ansible-collections/community.general/pull/11819). diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index e9a35eb79b..cc2146a68a 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -106,6 +106,18 @@ options: B(Do not use the generated string as a password or a secure token when using this option!) type: str version_added: 11.3.0 + avoid_special_first: + description: + - Avoid special characters at the first position of the string. + type: bool + default: false + version_added: 12.6.0 + avoid_special_last: + description: + - Avoid special characters at the last position of the string. + type: bool + default: false + version_added: 12.6.0 """ EXAMPLES = r""" @@ -155,6 +167,16 @@ EXAMPLES = r""" vars: hex_chars: '0123456789ABCDEF' # Example result: ['D2A40737'] + +- name: Generate random string with avoid_special_first + debug: + var: query('community.general.random_string', avoid_special_first=true) + # Example result: ['U@n0hUiX5kVG'] + +- name: Generate random string with avoid_special_last + debug: + var: query('community.general.random_string', avoid_special_last=true) + # Example result: ['U@n0hUiX5kVG'] """ RETURN = r""" @@ -177,12 +199,6 @@ from ansible_collections.community.general.plugins.plugin_utils._lookup import c class LookupModule(LookupBase): - @staticmethod - def get_random(random_generator, chars, length): - if not chars: - raise AnsibleLookupError("Available characters cannot be None, please change constraints") - return "".join(random_generator.choice(chars) for dummy in range(length)) - @staticmethod def b64encode(string_value, encoding="utf-8"): return to_text(base64.b64encode(to_bytes(string_value, encoding=encoding, errors="surrogate_or_strict"))) @@ -199,8 +215,11 @@ class LookupModule(LookupBase): length = self.get_option("length") base64_flag = self.get_option("base64") override_all = self.get_option("override_all") + override_special = self.get_option("override_special") ignore_similar_chars = self.get_option("ignore_similar_chars") similar_chars = self.get_option("similar_chars") + avoid_special_first = self.get_option("avoid_special_first") + avoid_special_last = self.get_option("avoid_special_last") seed = self.get_option("seed") if seed is None: @@ -208,7 +227,7 @@ class LookupModule(LookupBase): else: random_generator = random.Random(seed) - values = "" + result = [] available_chars_set = "" if ignore_similar_chars: @@ -225,7 +244,6 @@ class LookupModule(LookupBase): lower = self.get_option("lower") numbers = self.get_option("numbers") special = self.get_option("special") - override_special = self.get_option("override_special") if override_special: special_chars = override_special @@ -239,27 +257,60 @@ class LookupModule(LookupBase): if special: available_chars_set += special_chars - mapping = { - "min_numeric": number_chars, - "min_lower": lower_chars, - "min_upper": upper_chars, - "min_special": special_chars, - } + if len(available_chars_set) == 0: + raise AnsibleLookupError("Available characters cannot be empty, please change constraints") - for m in mapping: - if self.get_option(m): - values += self.get_random(random_generator, mapping[m], self.get_option(m)) + min_numeric = self.get_option("min_numeric") + min_lower = self.get_option("min_lower") + min_upper = self.get_option("min_upper") + min_special = self.get_option("min_special") + min_non_special = min_numeric + min_lower + min_upper + if avoid_special_first and avoid_special_last: + min_non_special = max(min_non_special, 2) + elif avoid_special_first or avoid_special_last: + min_non_special = max(min_non_special, 1) - remaining_pass_len = length - len(values) - values += self.get_random(random_generator, available_chars_set, remaining_pass_len) + if length < min_special + min_non_special: + raise AnsibleLookupError("Minimum requirements exceed total length, please increase the length") - shuffled_values = list(values) - if seed is None: - # Get pseudo randomization - # Randomize the order - random.shuffle(shuffled_values) + # Ensure minimum requirements + result += [random_generator.choice(number_chars) for dummy in range(min_numeric)] + result += [random_generator.choice(lower_chars) for dummy in range(min_lower)] + result += [random_generator.choice(upper_chars) for dummy in range(min_upper)] + if override_special: + result += [random_generator.choice(override_special) for dummy in range(min_special)] + else: + result += [random_generator.choice(special_chars) for dummy in range(min_special)] + + # Fill remaining length + remaining_length = length - len(result) + if min_non_special <= remaining_length: + # atleast one non-special character + available_chars_set = available_chars_set.replace(special_chars, "") + + result += [random_generator.choice(available_chars_set) for dummy in range(remaining_length)] + + # Shuffle to avoid predictable pattern + random_generator.shuffle(result) + + # Handle first/last character constraints + if avoid_special_first and result[0] in special_chars: + for i in range(1, len(result)): + if result[i] not in special_chars: + result[0], result[i] = result[i], result[0] + break + else: + raise AssertionError("No character satisfies the constraints for avoid_special_first") + + if avoid_special_last and result[-1] in special_chars: + for i in range(len(result) - 2, -1, -1): + if result[i] not in special_chars: + result[-1], result[i] = result[i], result[-1] + break + else: + raise AssertionError("No character satisfies the constraints for avoid_special_last") if base64_flag: - return [self.b64encode("".join(shuffled_values))] + return [self.b64encode("".join(result))] - return ["".join(shuffled_values)] + return ["".join(result)] diff --git a/tests/integration/targets/lookup_random_string/test.yml b/tests/integration/targets/lookup_random_string/test.yml index 31b5001ceb..52d581ac6b 100644 --- a/tests/integration/targets/lookup_random_string/test.yml +++ b/tests/integration/targets/lookup_random_string/test.yml @@ -11,7 +11,7 @@ result1: "{{ query('community.general.random_string') }}" result2: "{{ query('community.general.random_string', length=0) }}" result3: "{{ query('community.general.random_string', length=10) }}" - result4: "{{ query('community.general.random_string', length=-1) }}" + result4: "{{ query('community.general.random_string', length=6) }}" result5: "{{ query('community.general.random_string', override_special='_', min_special=1) }}" result6: "{{ query('community.general.random_string', upper=false, special=false) }}" # lower case only result7: "{{ query('community.general.random_string', lower=false, special=false) }}" # upper case only @@ -23,6 +23,9 @@ result13: "{{ query('community.general.random_string', override_all='0', length=2) }}" result14: "{{ query('community.general.random_string', seed='1234') }}" result15: "{{ query('community.general.random_string', seed='1234') }}" + result16: "{{ query('community.general.random_string', avoid_special_first=true) }}" + result17: "{{ query('community.general.random_string', avoid_special_last=true) }}" + result18: "{{ query('community.general.random_string', length=10, avoid_special_first=true, avoid_special_last=true, min_special=8) }}" - name: Raise error when impossible constraints are provided set_fact: @@ -30,13 +33,33 @@ ignore_errors: true register: impossible_result + - name: Raise error when override_all is provided with special characters and avoid_special_first is provided + set_fact: + error: "{{ query('community.general.random_string', override_all='!@#$%^&*()', avoid_special_first=true) }}" + ignore_errors: true + register: override_all_special_characters_avoid_special_first_error + + - name: Raise error when override_all is provided with special characters and avoid_special_last is provided + set_fact: + error: "{{ query('community.general.random_string', override_all='!@#$%^&*()', avoid_special_last=true) }}" + ignore_errors: true + register: override_all_special_characters_avoid_special_last_error + + - name: Raise error when impossible constraints are provided + set_fact: + error: "{{ query('community.general.random_string', length=10, avoid_special_first=true, avoid_special_last=true, min_special=9) }}" + ignore_errors: true + register: len_10_avoid_special_first_avoid_special_last_min_special_9 + + - debug: + var: len_10_avoid_special_first_avoid_special_last_min_special_9 - name: Check results assert: that: - result1[0] | length == 8 - result2[0] | length == 0 - result3[0] | length == 10 - - result4[0] | length == 0 + - result4[0] | length == 6 - result5[0] | length == 8 - "'_' in result5[0]" - result6[0] is lower @@ -54,3 +77,14 @@ - impossible_result is failed - "'Available characters cannot' in impossible_result.msg" - result15[0] == result14[0] + - result16[0][0] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result17[0][-1] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result18[0] | length == 10 + - result18[0][0] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result18[0][-1] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - override_all_special_characters_avoid_special_first_error is failed + - "'No character satisfies the constraints for avoid_special_first' in override_all_special_characters_avoid_special_first_error.msg" + - override_all_special_characters_avoid_special_last_error is failed + - "'No character satisfies the constraints for avoid_special_last' in override_all_special_characters_avoid_special_last_error.msg" + - len_10_avoid_special_first_avoid_special_last_min_special_9 is failed + - "'Minimum requirements exceed total length, please increase the length' in len_10_avoid_special_first_avoid_special_last_min_special_9.msg"