Repository: ansible-collections/community.general

Issue:
pamd: module argument removal doesn't work with state=updated
### Summary

When I try to execute the documented way to remove all the module arguments of an existing rule, it doesn't change anything. Current documentation on https://docs.ansible.com/ansible/latest/collections/community/general/pamd_module.html contains the following example

```
- name: Remove module arguments from an existing rule
  community.general.pamd:
    name: system-auth
    type: auth
    control: required
    module_path: pam_faillock.so
    module_arguments: ''
    state: updated
```

This simply cannot work, because update_rule method in plugins/modules/system/pamd.py calls parse_module_arguments with the list [''] but it returns with an empty list [], which will be the new value of new_args. After this, the conditional beginning with `if new_args:` will never be executed, so the rule is unchanged.

Please find my proposed fix and a test case attached (in patch format).
[ansible_collections_community.general.pamd_remove_module_args_fix.patch.txt](https://github.com/ansible-collections/community.general/files/7041216/ansible_collections_community.general.pamd_remove_module_args_fix.patch.txt)


### Issue Type

Bug Report

### Component Name

pamd (community.general.pamd)

### Ansible Version

```
$ ansible --version
ansible 2.10.8
  config file = None
  configured module search path = ['/home/gburjan/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  executable location = /usr/bin/ansible
  python version = 3.9.2 (default, Feb 28 2021, 17:03:44) [GCC 10.2.1 20210110]

```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed
(empty list)
```


### OS / Environment

Debian 11 (but I've also tested on older versions too and checked the latest code of this module on github)

### Steps to Reproduce

```
# echo "auth       required pam_env.so foo bar" > /etc/pam.d/test0823
```
Execute the following playbook


<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

- hosts: all
  connection: local
  become: yes
  tasks:
    - name: Remove module arguments from an existing rule
      community.general.pamd:
        name: test0823
        type: auth
        control: required
        module_path: pam_env.so
        module_arguments: ''
        state: updated
```


### Expected Results

Expected result: module arguments are removed.

### Actual Results

File is unchanged.

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/system/pamd.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/system/pamd.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @kevensen
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
CC @russoz 

Resolving patch:
diff --git a/plugins/modules/system/pamd.py b/plugins/modules/system/pamd.py
--- a/plugins/modules/system/pamd.py
+++ b/plugins/modules/system/pamd.py
@@ -274,8 +274,7 @@
                         (?P<control>\[.*\]|\S*)\s+
                         (?P<path>\S*)\s*
                         (?P<args>.*)\s*""", re.X)
-
-RULE_ARG_REGEX = re.compile(r"""(\[.*\]|\S*)""")
+RULE_ARG_REGEX = re.compile(r"(\[.*\]|\S*)")
 
 VALID_TYPES = ['account', '-account', 'auth', '-auth', 'password', '-password', 'session', '-session']
 
@@ -358,11 +357,9 @@ def __init__(self, rule_type, rule_control, rule_path, rule_args=None):
 
     # Method to check if a rule matches the type, control and path.
     def matches(self, rule_type, rule_control, rule_path, rule_args=None):
-        if (rule_type == self.rule_type and
+        return (rule_type == self.rule_type and
                 rule_control == self.rule_control and
-                rule_path == self.rule_path):
-            return True
-        return False
+                rule_path == self.rule_path)
 
     @classmethod
     def rule_from_string(cls, line):
@@ -507,25 +504,25 @@ def update_rule(self, rule_type, rule_control, rule_path,
         # Get a list of rules we want to change
         rules_to_find = self.get(rule_type, rule_control, rule_path)
 
-        new_args = parse_module_arguments(new_args)
+        new_args = parse_module_arguments(new_args, return_none=True)
 
         changes = 0
         for current_rule in rules_to_find:
             rule_changed = False
             if new_type:
-                if(current_rule.rule_type != new_type):
+                if current_rule.rule_type != new_type:
                     rule_changed = True
                     current_rule.rule_type = new_type
             if new_control:
-                if(current_rule.rule_control != new_control):
+                if current_rule.rule_control != new_control:
                     rule_changed = True
                     current_rule.rule_control = new_control
             if new_path:
-                if(current_rule.rule_path != new_path):
+                if current_rule.rule_path != new_path:
                     rule_changed = True
                     current_rule.rule_path = new_path
-            if new_args:
-                if(current_rule.rule_args != new_args):
+            if new_args is not None:
+                if current_rule.rule_args != new_args:
                     rule_changed = True
                     current_rule.rule_args = new_args
 
@@ -724,8 +721,9 @@ def validate(self):
         current_line = self._head
 
         while current_line is not None:
-            if not current_line.validate()[0]:
-                return current_line.validate()
+            curr_validate = current_line.validate()
+            if not curr_validate[0]:
+                return curr_validate
             current_line = current_line.next
         return True, "Module is valid"
 
@@ -750,22 +748,25 @@ def __str__(self):
         return '\n'.join(lines) + '\n'
 
 
-def parse_module_arguments(module_arguments):
-    # Return empty list if we have no args to parse
-    if not module_arguments:
-        return []
-    elif isinstance(module_arguments, list) and len(module_arguments) == 1 and not module_arguments[0]:
+def parse_module_arguments(module_arguments, return_none=False):
+    # If args is None, return empty list by default.
+    # But if return_none is True, then return None
+    if module_arguments is None:
+        return None if return_none else []
+    if isinstance(module_arguments, list) and len(module_arguments) == 1 and not module_arguments[0]:
         return []
 
     if not isinstance(module_arguments, list):
         module_arguments = [module_arguments]
 
-    parsed_args = list()
+    # From this point on, module_arguments is guaranteed to be a list, empty or not
+    parsed_args = []
 
+    re_clear_spaces = re.compile(r"\s*=\s*")
     for arg in module_arguments:
         for item in filter(None, RULE_ARG_REGEX.findall(arg)):
             if not item.startswith("["):
-                re.sub("\\s*=\\s*", "=", item)
+                re_clear_spaces.sub("=", item)
             parsed_args.append(item)
 
     return parsed_args
@@ -861,8 +862,7 @@ def main():
                 fd.write(str(service))
 
         except IOError:
-            module.fail_json(msg='Unable to create temporary \
-                                    file %s' % temp_file)
+            module.fail_json(msg='Unable to create temporary file %s' % temp_file)
 
         module.atomic_move(temp_file.name, os.path.realpath(fname))
 


Test patch:
diff --git a/tests/integration/targets/pamd/tasks/main.yml b/tests/integration/targets/pamd/tasks/main.yml
--- a/tests/integration/targets/pamd/tasks/main.yml
+++ b/tests/integration/targets/pamd/tasks/main.yml
@@ -5,11 +5,10 @@
   set_fact:
     test_pamd_file: "/tmp/pamd_file"
 
-- name: Copy temporary pam.d file
+- name: Create temporary pam.d file
   copy:
     content: "session required pam_lastlog.so silent showfailed"
     dest: "{{ test_pamd_file }}"
-
 - name: Test working on a single-line file works (2925)
   community.general.pamd:
     path: /tmp
@@ -20,17 +19,37 @@
     module_arguments: silent
     state: args_absent
   register: pamd_file_output
-
 - name: Check if changes made
   assert:
     that:
       - pamd_file_output is changed
 
-- name: Copy temporary pam.d file
+- name: Test removing all arguments from an entry (3260)
+  community.general.pamd:
+    path: /tmp
+    name: pamd_file
+    type: session
+    control: required
+    module_path: pam_lastlog.so
+    module_arguments: ""
+    state: updated
+  register: pamd_file_output_noargs
+- name: Read back the file (3260)
+  slurp:
+    src: "{{ test_pamd_file }}"
+  register: pamd_file_slurp_noargs
+- name: Check if changes made (3260)
+  vars:
+    line_array: "{{ (pamd_file_slurp_noargs.content|b64decode).split('\n')[2].split() }}"
+  assert:
+    that:
+      - pamd_file_output_noargs is changed
+      - line_array == ['session', 'required', 'pam_lastlog.so']
+
+- name: Create temporary pam.d file
   copy:
     content: ""
     dest: "{{ test_pamd_file }}"
-
 # This test merely demonstrates that, as-is, module will not perform any changes on an empty file
 # All the existing values for "state" will first search for a rule matching type, control, module_path
 # and will not perform any change whatsoever if no existing rules match.
@@ -43,12 +62,10 @@
     module_path: pam_lastlog.so
     module_arguments: silent
   register: pamd_file_output_empty
-
 - name: Read back the file
   slurp:
     src: "{{ test_pamd_file }}"
   register: pamd_file_slurp
-
 - name: Check if changes made
   assert:
     that:
diff --git a/tests/unit/plugins/modules/system/test_pamd.py b/tests/unit/plugins/modules/system/test_pamd.py
--- a/tests/unit/plugins/modules/system/test_pamd.py
+++ b/tests/unit/plugins/modules/system/test_pamd.py
@@ -218,6 +218,14 @@ def test_update_rule_module_args(self):
         test_rule = PamdRule('auth', 'sufficient', 'pam_unix.so', 'nullok try_first_pass')
         self.assertNotIn(str(test_rule), str(self.pamd))
 
+    def test_update_rule_remove_module_args(self):
+        self.assertTrue(self.pamd.update_rule('auth', 'sufficient', 'pam_unix.so', new_args=''))
+        test_rule = PamdRule('auth', 'sufficient', 'pam_unix.so', '')
+        self.assertIn(str(test_rule), str(self.pamd))
+
+        test_rule = PamdRule('auth', 'sufficient', 'pam_unix.so', 'nullok try_first_pass')
+        self.assertNotIn(str(test_rule), str(self.pamd))
+
     def test_update_first_three(self):
         self.assertTrue(self.pamd.update_rule('auth', 'required', 'pam_env.so',
                                               new_type='one', new_control='two', new_path='three'))