Repository: ansible-collections/community.general

Issue:
Homebrew Cask does not process cask-names containing hyphens '-' properly
##### SUMMARY
Homebrew Cask does not process cask-names containing hyphens '-' properly. When a hyphen '-' is present, the cask will not be installed claiming that the cask is not present.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
homebrew_cask

##### ANSIBLE VERSION
```
ansible 2.10.1
  config file = /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg
  configured module search path = [u'/Users/michaelwasher/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/michaelwasher/Library/Python/2.7/lib/python/site-packages/ansible
  executable location = /Users/michaelwasher/Library/Python/2.7/bin//ansible
  python version = 2.7.16 (default, Jun  5 2020, 22:59:21) [GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.29.20) (-macos10.15-objc-
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```
DEFAULT_BECOME(/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg) = False
DEFAULT_BECOME_ASK_PASS(/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg) = False
DEFAULT_BECOME_METHOD(/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg) = sudo
DEFAULT_BECOME_USER(/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg) = root
DEFAULT_HOST_LIST(/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg) = [u'/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory']
DEFAULT_KEEP_REMOTE_FILES(env: ANSIBLE_KEEP_REMOTE_FILES) = True
```

##### OS / ENVIRONMENT
Target OS: Mac OSX 10.15.7

##### STEPS TO REPRODUCE
Run the provided playbook below to install a package in the Mac Environment. It appears as though this fails for all packages containing a '-'.
```
---
- name: Install Homebrew
  hosts: localhost
  become: false
  tasks:
    - name: Install Cask Package through Homebrew
      community.general.homebrew_cask:
        name: visual-studio-code
        state: present
        update_homebrew: true
```

##### EXPECTED RESULTS
This will install the `visual-studio-code` package.

##### ACTUAL RESULTS
This does not install the `visual-studio-code` package, instead logging an error for an invalid-cask.

```
michaelwasher@Michaels-MacBook-Pro ansible_dev_conf % ansible-playbook ./tmp.yml  -vvvv                                                             

ansible-playbook 2.10.1
  config file = /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg
  configured module search path = [u'/Users/michaelwasher/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/michaelwasher/Library/Python/2.7/lib/python/site-packages/ansible
  executable location = /Users/michaelwasher/Library/Python/2.7/bin//ansible-playbook
  python version = 2.7.16 (default, Jun  5 2020, 22:59:21) [GCC 4.2.1 Compatible Apple LLVM 11.0.3 (clang-1103.0.29.20) (-macos10.15-objc-
Using /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/ansible.cfg as config file
setting up inventory plugins
host_list declined parsing /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory as it did not pass its verify_file() method
script declined parsing /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory as it did not pass its verify_file() method
auto declined parsing /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory as it did not pass its verify_file() method
Set default localhost to localhost
Parsed /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory inventory source with ini plugin
Loading collection community.general from /Users/michaelwasher/.ansible/collections/ansible_collections/community/general
Loading callback plugin default of type stdout, v2.0 from /Users/michaelwasher/Library/Python/2.7/lib/python/site-packages/ansible/plugins/callback/default.pyc

PLAYBOOK: tmp.yml **************************************************************************************************************************************************************************************************************************
Positional arguments: ./tmp.yml
become_method: sudo
inventory: (u'/Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/inventory',)
forks: 5
tags: (u'all',)
verbosity: 4
connection: smart
timeout: 10
1 plays in ./tmp.yml

PLAY [Install Homebrew] ********************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] *********************************************************************************************************************************************************************************************************************
task path: /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/tmp.yml:2
<localhost> ESTABLISH LOCAL CONNECTION FOR USER: michaelwasher
<localhost> EXEC /bin/sh -c 'echo ~michaelwasher && sleep 0'
<localhost> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /Users/michaelwasher/.ansible/tmp `"&& mkdir "` echo /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992 `" && echo ansible-tmp-1601770695.22-70144-75935192927992="` echo /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992 `" ) && sleep 0'
<localhost> Attempting python interpreter discovery
<localhost> EXEC /bin/sh -c 'echo PLATFORM; uname; echo FOUND; command -v '"'"'/usr/bin/python'"'"'; command -v '"'"'python3.7'"'"'; command -v '"'"'python3.6'"'"'; command -v '"'"'python3.5'"'"'; command -v '"'"'python2.7'"'"'; command -v '"'"'python2.6'"'"'; command -v '"'"'/usr/libexec/platform-python'"'"'; command -v '"'"'/usr/bin/python3'"'"'; command -v '"'"'python'"'"'; echo ENDFOUND && sleep 0'
<localhost> Python interpreter discovery fallback (unsupported platform for extended discovery: darwin)
Using module file /Users/michaelwasher/Library/Python/2.7/lib/python/site-packages/ansible/modules/setup.py
<localhost> PUT /Users/michaelwasher/.ansible/tmp/ansible-local-70137AWmosn/tmp8Bs5Lu TO /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992/AnsiballZ_setup.py
<localhost> EXEC /bin/sh -c 'chmod u+x /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992/ /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992/AnsiballZ_setup.py && sleep 0'
<localhost> EXEC /bin/sh -c '/usr/bin/python /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770695.22-70144-75935192927992/AnsiballZ_setup.py && sleep 0'
[WARNING]: Platform darwin on host localhost is using the discovered Python interpreter at /usr/bin/python, but future installation of another Python interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible/2.10/reference_appendices/interpreter_discovery.html for more information.
ok: [localhost]
META: ran handlers

TASK [Install Cask Package through Homebrew] ***********************************************************************************************************************************************************************************************
task path: /Users/michaelwasher/Desktop/UserFolder/ansible_dev_conf/tmp.yml:6
<localhost> ESTABLISH LOCAL CONNECTION FOR USER: michaelwasher
<localhost> EXEC /bin/sh -c 'echo ~michaelwasher && sleep 0'
<localhost> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /Users/michaelwasher/.ansible/tmp `"&& mkdir "` echo /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686 `" && echo ansible-tmp-1601770696.88-70178-251044235237686="` echo /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686 `" ) && sleep 0'
Using module file /Users/michaelwasher/.ansible/collections/ansible_collections/community/general/plugins/modules/homebrew_cask.py
<localhost> PUT /Users/michaelwasher/.ansible/tmp/ansible-local-70137AWmosn/tmpoAdY3T TO /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686/AnsiballZ_homebrew_cask.py
<localhost> EXEC /bin/sh -c 'chmod u+x /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686/ /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686/AnsiballZ_homebrew_cask.py && sleep 0'
<localhost> EXEC /bin/sh -c '/usr/bin/python /Users/michaelwasher/.ansible/tmp/ansible-tmp-1601770696.88-70178-251044235237686/AnsiballZ_homebrew_cask.py && sleep 0'
fatal: [localhost]: FAILED! => {
    "changed": false, 
    "invocation": {
        "module_args": {
            "accept_external_apps": false, 
            "greedy": false, 
            "install_options": [], 
            "name": [
                "visual-studio-code"
            ], 
            "path": "/usr/local/bin", 
            "state": "present", 
            "sudo_password": null, 
            "update_homebrew": true, 
            "upgrade_all": false
        }
    }, 
    "msg": "Invalid cask: visual-studio-code."
}

PLAY RECAP *********************************************************************************************************************************************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0 
```



Discussion:
Files identified in the description:
* [`plugins/modules/packaging/os/homebrew_cask.py`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/packaging/os/homebrew_cask.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 @akasurde @danieljaouen @enriclluelles @indrajitr @kyleabenson @martinm82
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
When this is passed as a raw-string in `_create_regex_group(s):` this will be seen by the regex compiler as a grouping element and not a raw `\-`
https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/homebrew_cask.py#L188
/assign
It looks like this also affects the Homebrew module
https://github.com/ansible-collections/community.general/blob/main/plugins/modules/packaging/os/homebrew.py#L205

Resolving patch:
diff --git a/plugins/modules/packaging/os/homebrew.py b/plugins/modules/packaging/os/homebrew.py
--- a/plugins/modules/packaging/os/homebrew.py
+++ b/plugins/modules/packaging/os/homebrew.py
@@ -168,7 +168,7 @@ class HomebrewException(Exception):
 
 
 # utils ------------------------------------------------------------------- {{{
-def _create_regex_group(s):
+def _create_regex_group_complement(s):
     lines = (line.strip() for line in s.split('\n') if line.strip())
     chars = filter(None, (line.split('#')[0].strip() for line in lines))
     group = r'[^' + r''.join(chars) + r']'
@@ -186,7 +186,7 @@ class Homebrew(object):
         :                   # colons
         {sep}               # the OS-specific path separator
         .                   # dots
-        -                   # dashes
+        \-                  # dashes
     '''.format(sep=os.path.sep)
 
     VALID_BREW_PATH_CHARS = r'''
@@ -194,7 +194,7 @@ class Homebrew(object):
         \s                  # spaces
         {sep}               # the OS-specific path separator
         .                   # dots
-        -                   # dashes
+        \-                  # dashes
     '''.format(sep=os.path.sep)
 
     VALID_PACKAGE_CHARS = r'''
@@ -202,14 +202,14 @@ class Homebrew(object):
         .                   # dots
         /                   # slash (for taps)
         \+                  # plusses
-        -                   # dashes
+        \-                  # dashes
         :                   # colons (for URLs)
         @                   # at-sign
     '''
 
-    INVALID_PATH_REGEX = _create_regex_group(VALID_PATH_CHARS)
-    INVALID_BREW_PATH_REGEX = _create_regex_group(VALID_BREW_PATH_CHARS)
-    INVALID_PACKAGE_REGEX = _create_regex_group(VALID_PACKAGE_CHARS)
+    INVALID_PATH_REGEX = _create_regex_group_complement(VALID_PATH_CHARS)
+    INVALID_BREW_PATH_REGEX = _create_regex_group_complement(VALID_BREW_PATH_CHARS)
+    INVALID_PACKAGE_REGEX = _create_regex_group_complement(VALID_PACKAGE_CHARS)
     # /class regexes ----------------------------------------------- }}}
 
     # class validations -------------------------------------------- {{{
diff --git a/plugins/modules/packaging/os/homebrew_cask.py b/plugins/modules/packaging/os/homebrew_cask.py
--- a/plugins/modules/packaging/os/homebrew_cask.py
+++ b/plugins/modules/packaging/os/homebrew_cask.py
@@ -152,7 +152,7 @@ class HomebrewCaskException(Exception):
 
 
 # utils ------------------------------------------------------------------- {{{
-def _create_regex_group(s):
+def _create_regex_group_complement(s):
     lines = (line.strip() for line in s.split('\n') if line.strip())
     chars = filter(None, (line.split('#')[0].strip() for line in lines))
     group = r'[^' + r''.join(chars) + r']'
@@ -170,7 +170,7 @@ class HomebrewCask(object):
         :                   # colons
         {sep}               # the OS-specific path separator
         .                   # dots
-        -                   # dashes
+        \-                  # dashes
     '''.format(sep=os.path.sep)
 
     VALID_BREW_PATH_CHARS = r'''
@@ -178,20 +178,20 @@ class HomebrewCask(object):
         \s                  # spaces
         {sep}               # the OS-specific path separator
         .                   # dots
-        -                   # dashes
+        \-                  # dashes
     '''.format(sep=os.path.sep)
 
     VALID_CASK_CHARS = r'''
         \w                  # alphanumeric characters (i.e., [a-zA-Z0-9_])
         .                   # dots
         /                   # slash (for taps)
-        -                   # dashes
+        \-                  # dashes
         @                   # at symbol
     '''
 
-    INVALID_PATH_REGEX = _create_regex_group(VALID_PATH_CHARS)
-    INVALID_BREW_PATH_REGEX = _create_regex_group(VALID_BREW_PATH_CHARS)
-    INVALID_CASK_REGEX = _create_regex_group(VALID_CASK_CHARS)
+    INVALID_PATH_REGEX = _create_regex_group_complement(VALID_PATH_CHARS)
+    INVALID_BREW_PATH_REGEX = _create_regex_group_complement(VALID_BREW_PATH_CHARS)
+    INVALID_CASK_REGEX = _create_regex_group_complement(VALID_CASK_CHARS)
     # /class regexes ----------------------------------------------- }}}
 
     # class validations -------------------------------------------- {{{


Test patch:
diff --git a/tests/unit/plugins/modules/packaging/os/test_homebrew.py b/tests/unit/plugins/modules/packaging/os/test_homebrew.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/packaging/os/test_homebrew.py
@@ -0,0 +1,22 @@
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+from ansible_collections.community.general.tests.unit.compat import unittest
+from ansible_collections.community.general.plugins.modules.packaging.os.homebrew import Homebrew
+
+
+class TestHomebrewModule(unittest.TestCase):
+
+    def setUp(self):
+        self.brew_app_names = [
+            'git-ssh',
+            'awscli@1',
+            'bash'
+        ]
+
+    def test_valid_package_names(self):
+        for name in self.brew_app_names:
+            self.assertTrue(Homebrew.valid_package(name))
diff --git a/tests/unit/plugins/modules/packaging/os/test_homebrew_cask.py b/tests/unit/plugins/modules/packaging/os/test_homebrew_cask.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/packaging/os/test_homebrew_cask.py
@@ -0,0 +1,21 @@
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+
+__metaclass__ = type
+
+from ansible_collections.community.general.tests.unit.compat import unittest
+from ansible_collections.community.general.plugins.modules.packaging.os.homebrew_cask import HomebrewCask
+
+
+class TestHomebrewCaskModule(unittest.TestCase):
+
+    def setUp(self):
+        self.brew_cask_names = [
+            'visual-studio-code',
+            'firefox'
+        ]
+
+    def test_valid_cask_names(self):
+        for name in self.brew_cask_names:
+            self.assertTrue(HomebrewCask.valid_cask(name))