Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package ansible-core for openSUSE:Factory 
checked in at 2026-11-06 19:55:27
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/ansible-core (Old)
 and      /work/SRC/openSUSE:Factory/.ansible-core.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "ansible-core"

Fri Nov  6 19:55:27 2026 rev:68 rq:1395737 version:2.21.6

Changes:
--------
--- /work/SRC/openSUSE:Factory/ansible-core/ansible-core.changes        
2026-10-18 19:25:20.670836665 +0200
+++ /work/SRC/openSUSE:Factory/.ansible-core.new.3759290/ansible-core.changes   
2026-11-06 20:00:44.406122067 +0100
@@ -1,0 +2,30 @@
+Fri Nov  6 08:45:29 UTC 2026 - Johannes Kastl 
<[email protected]>
+
+- update to 2.21.6:
+  
https://github.com/ansible/ansible/blob/v2.21.6/changelogs/CHANGELOG-v2.21.rst
+  * Minor Changes
+    - ansible-test - Replace FreeBSD 14.4 remote with 14.5.
+    - ansible-test - Replace FreeBSD 15.0 remote with 15.1.
+    - ansible-test - Replace RHEL 10.1 remote with 10.2.
+    - ansible-test - Replace RHEL 9.7 remote with 9.8.
+  * Security Fixes
+    - ansible-galaxy collection install - fix validating FILES.json
+      entries are contained in the collection before creating them.
+      (CVE-2026-89091)
+    - ansible-galaxy role install - fix validating the role name is
+      contained to the configured role path. (CVE-2026-105929)
+  * Bugfixes
+    - ansible_facts - initial facts from run_once tasks no longer
+      reference the same object and can be updated for individual
+      hosts
+    - git module - updated to avoid possible incorrect parameters
+      read from the repo targets. CVE-2026-84503
+    - module_utils.mask_url - handle exceptions on parseurl to
+      avoid disclosing url itself.
+    - sudo become plugin, sudo-rs compatibility fix for non english
+      locales
+    - user - On BusyBox systems, preserve the existing password
+      lock marker when password_lock is omitted
+- remove ansible-core-CVE-2026-89091.patch
+
+-------------------------------------------------------------------

Old:
----
  ansible-core-CVE-2026-89091.patch
  ansible_core-2.21.5.tar.gz
  ansible_core-2.21.5.tar.gz.sha256

New:
----
  ansible_core-2.21.6.tar.gz
  ansible_core-2.21.6.tar.gz.sha256

----------(Old B)----------
  Old:      lock marker when password_lock is omitted
- remove ansible-core-CVE-2026-89091.patch
----------(Old E)----------

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ ansible-core.spec ++++++
--- /var/tmp/diff_new_pack.HF1NMW/_old  2026-11-06 20:00:46.925227518 +0100
+++ /var/tmp/diff_new_pack.HF1NMW/_new  2026-11-06 20:00:46.930227727 +0100
@@ -43,7 +43,7 @@
 %endif
 
 Name:           ansible-core
-Version:        2.21.5
+Version:        2.21.6
 Release:        0
 Summary:        Radically simple IT automation
 License:        GPL-3.0-or-later
@@ -52,8 +52,6 @@
 Source1:        ansible_core-%{version}.tar.gz.sha256
 # CVE-2026-16493 (bsc#1272369)
 Patch0:         ansible-core-CVE-2026-16493.patch
-# CVE-2026-89091 (bsc#1284847)
-Patch1:         ansible-core-CVE-2026-89091.patch
 BuildArch:      noarch
 
 # cannot be installed with ansible < 3 or ansible-base

++++++ ansible_core-2.21.5.tar.gz -> ansible_core-2.21.6.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/PKG-INFO 
new/ansible_core-2.21.6/PKG-INFO
--- old/ansible_core-2.21.5/PKG-INFO    2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/PKG-INFO    2026-11-02 18:32:18.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: ansible-core
-Version: 2.21.5
+Version: 2.21.6
 Summary: Radically simple IT automation
 Author: Ansible Project
 License-Expression: GPL-3.0-or-later
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/ansible_core.egg-info/PKG-INFO 
new/ansible_core-2.21.6/ansible_core.egg-info/PKG-INFO
--- old/ansible_core-2.21.5/ansible_core.egg-info/PKG-INFO      2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/ansible_core.egg-info/PKG-INFO      2026-11-02 
18:32:18.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: ansible-core
-Version: 2.21.5
+Version: 2.21.6
 Summary: Radically simple IT automation
 Author: Ansible Project
 License-Expression: GPL-3.0-or-later
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/ansible_core.egg-info/SOURCES.txt 
new/ansible_core-2.21.6/ansible_core.egg-info/SOURCES.txt
--- old/ansible_core-2.21.5/ansible_core.egg-info/SOURCES.txt   2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/ansible_core.egg-info/SOURCES.txt   2026-11-02 
18:32:18.000000000 +0100
@@ -1020,12 +1020,14 @@
 test/integration/targets/ansible-galaxy-collection/handlers/main.yml
 test/integration/targets/ansible-galaxy-collection/meta/main.yml
 test/integration/targets/ansible-galaxy-collection/tasks/build.yml
+test/integration/targets/ansible-galaxy-collection/tasks/build_and_install_tarball.yml
 
test/integration/targets/ansible-galaxy-collection/tasks/check_requires_ansible.yml
 test/integration/targets/ansible-galaxy-collection/tasks/download.yml
 
test/integration/targets/ansible-galaxy-collection/tasks/fail_fast_resolvelib.yml
 test/integration/targets/ansible-galaxy-collection/tasks/init.yml
 test/integration/targets/ansible-galaxy-collection/tasks/install.yml
 test/integration/targets/ansible-galaxy-collection/tasks/install_offline.yml
+test/integration/targets/ansible-galaxy-collection/tasks/install_tarball.yml
 test/integration/targets/ansible-galaxy-collection/tasks/list.yml
 test/integration/targets/ansible-galaxy-collection/tasks/main.yml
 
test/integration/targets/ansible-galaxy-collection/tasks/pinned_pre_releases_in_deptree.yml
@@ -5069,7 +5071,6 @@
 test/units/cli/test_playbook.py
 test/units/cli/test_vault.py
 test/units/cli/arguments/test_option_helpers.py
-test/units/cli/galaxy/test_collection_extract_tar.py
 test/units/cli/galaxy/test_display_collection.py
 test/units/cli/galaxy/test_display_header.py
 test/units/cli/galaxy/test_display_role.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/changelogs/CHANGELOG-v2.21.rst 
new/ansible_core-2.21.6/changelogs/CHANGELOG-v2.21.rst
--- old/ansible_core-2.21.5/changelogs/CHANGELOG-v2.21.rst      2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/changelogs/CHANGELOG-v2.21.rst      2026-11-02 
18:32:18.000000000 +0100
@@ -4,6 +4,38 @@
 
 .. contents:: Topics
 
+v2.21.6
+=======
+
+Release Summary
+---------------
+
+| Release Date: 2026-11-02
+| `Porting Guide 
<https://docs.ansible.com/ansible-core/2.21/porting_guides/porting_guide_core_2.21.html>`__
+
+Minor Changes
+-------------
+
+- ansible-test - Replace FreeBSD 14.4 remote with 14.5.
+- ansible-test - Replace FreeBSD 15.0 remote with 15.1.
+- ansible-test - Replace RHEL 10.1 remote with 10.2.
+- ansible-test - Replace RHEL 9.7 remote with 9.8.
+
+Security Fixes
+--------------
+
+- ``ansible-galaxy collection install`` - fix validating ``FILES.json`` 
entries are contained in the collection before creating them. (CVE-2026-89091)
+- ``ansible-galaxy role install`` - fix validating the role name is contained 
to the configured role path. (CVE-2026-105929)
+
+Bugfixes
+--------
+
+- ansible_facts - initial facts from ``run_once`` tasks no longer reference 
the same object and can be updated for individual hosts
+- git module - updated to avoid possible incorrect parameters read from the 
repo targets. CVE-2026-84503
+- module_utils.mask_url - handle exceptions on parseurl to avoid disclosing 
url itself.
+- sudo become plugin, sudo-rs compatibility fix for non english locales
+- user - On BusyBox systems, preserve the existing password lock marker when 
``password_lock`` is omitted.
+
 v2.21.5
 =======
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/changelogs/changelog.yaml 
new/ansible_core-2.21.6/changelogs/changelog.yaml
--- old/ansible_core-2.21.5/changelogs/changelog.yaml   2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/changelogs/changelog.yaml   2026-11-02 
18:32:18.000000000 +0100
@@ -852,3 +852,55 @@
     - dnf5-download_dir-download_only.yml
     - ensure_conn_cli_dir_perms.yml
     release_date: '2026-09-29'
+  2.21.6:
+    changes:
+      release_summary: '| Release Date: 2026-11-02
+
+        | `Porting Guide 
<https://docs.ansible.com/ansible-core/2.21/porting_guides/porting_guide_core_2.21.html>`__
+
+        '
+    codename: The Rain Song
+    fragments:
+    - 2.21.6_summary.yaml
+    release_date: '2026-11-02'
+  2.21.6rc1:
+    changes:
+      bugfixes:
+      - ansible_facts - initial facts from ``run_once`` tasks no longer 
reference
+        the same object and can be updated for individual hosts
+      - git module - updated to avoid possible incorrect parameters read from 
the
+        repo targets. CVE-2026-84503
+      - module_utils.mask_url - handle exceptions on parseurl to avoid 
disclosing
+        url itself.
+      - sudo become plugin, sudo-rs compatibility fix for non english locales
+      - user - On BusyBox systems, preserve the existing password lock marker 
when
+        ``password_lock`` is omitted.
+      minor_changes:
+      - ansible-test - Replace FreeBSD 14.4 remote with 14.5.
+      - ansible-test - Replace FreeBSD 15.0 remote with 15.1.
+      - ansible-test - Replace RHEL 10.1 remote with 10.2.
+      - ansible-test - Replace RHEL 9.7 remote with 9.8.
+      release_summary: '| Release Date: 2026-10-27
+
+        | `Porting Guide 
<https://docs.ansible.com/ansible-core/2.21/porting_guides/porting_guide_core_2.21.html>`__
+
+        '
+      security_fixes:
+      - '``ansible-galaxy collection install`` - fix validating ``FILES.json`` 
entries
+        are contained in the collection before creating them. (CVE-2026-89091)'
+      - '``ansible-galaxy role install`` - fix validating the role name is 
contained
+        to the configured role path. (CVE-2026-105929)'
+    codename: The Rain Song
+    fragments:
+    - 2.21.6rc1_summary.yaml
+    - 87415-set-host-facts-copy.yml
+    - ansible-galaxy-collection-check-path-containment.yml
+    - ansible-galaxy-role-name-containment.yml
+    - ansible-test-freebsd.yml
+    - ansible-test-rhel.yml
+    - bump-freebsd-15-0-to-15-1.yml
+    - git_fixes.yml
+    - mask_url_except.yml
+    - sudo_moar_rs.yml
+    - user-busybox-password-lock.yml
+    release_date: '2026-10-27'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/cli/galaxy.py 
new/ansible_core-2.21.6/lib/ansible/cli/galaxy.py
--- old/ansible_core-2.21.5/lib/ansible/cli/galaxy.py   2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/cli/galaxy.py   2026-11-02 
18:32:18.000000000 +0100
@@ -60,6 +60,7 @@
 from ansible.template import trust_as_template
 from ansible.utils.collection_loader import AnsibleCollectionConfig
 from ansible.utils.display import Display
+from ansible.utils.path import is_subpath
 from ansible.utils.plugin_docs import get_versioned_doclink
 from ansible.utils.vars import load_extra_vars
 
@@ -1473,6 +1474,15 @@
 
             display.vvv('Processing role %s ' % role.name)
 
+            if not any(
+                is_subpath(os.path.join(path, role.name), path, real=True)
+                for path in context.CLIARGS['roles_path']
+            ):
+                display.warning(f"The role name '{role.name}' resolves to a 
path outside of the configured role directory")
+                self.exit_without_ignore()
+                display.display(f'- skipping role {role.name}')
+                continue
+
             # query the galaxy API for the role data
 
             if role.install_info is not None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/lib/ansible/galaxy/collection/__init__.py 
new/ansible_core-2.21.6/lib/ansible/galaxy/collection/__init__.py
--- old/ansible_core-2.21.5/lib/ansible/galaxy/collection/__init__.py   
2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/galaxy/collection/__init__.py   
2026-11-02 18:32:18.000000000 +0100
@@ -1682,6 +1682,9 @@
     except KeyError:
         raise AnsibleError("Unable to extract '%s' from collection" % dirname)
 
+    if not _is_child_path(to_bytes(tar_member.name, 
errors='surrogate_or_strict'), b_dest):
+        raise AnsibleError(f"Cannot extract directory '{dirname}' in 
collection: path is outside of collection '{tar_member.name}'")
+
     b_dir_path = os.path.join(b_dest, to_bytes(dirname, 
errors='surrogate_or_strict'))
 
     b_parent_path = os.path.dirname(b_dir_path)
@@ -1786,13 +1789,16 @@
 def _is_child_path(path, parent_path, link_name=None):
     """ Checks that path is a path within the parent_path specified. """
     b_path = to_bytes(path, errors='surrogate_or_strict')
+    b_parent_path = to_bytes(parent_path, errors='surrogate_or_strict')
 
     if link_name and not os.path.isabs(b_path):
         # If link_name is specified, path is the source of the link and we 
need to resolve the absolute path.
         b_link_dir = os.path.dirname(to_bytes(link_name, 
errors='surrogate_or_strict'))
-        b_path = os.path.abspath(os.path.join(b_link_dir, b_path))
+        b_path = os.path.join(b_link_dir, b_path)
+    elif not os.path.isabs(b_path):
+        b_path = os.path.join(b_parent_path, b_path)
 
-    b_parent_path = to_bytes(parent_path, errors='surrogate_or_strict')
+    b_path = os.path.normpath(b_path)
     return b_path == b_parent_path or b_path.startswith(b_parent_path + 
to_bytes(os.path.sep))
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/lib/ansible/module_utils/ansible_release.py 
new/ansible_core-2.21.6/lib/ansible/module_utils/ansible_release.py
--- old/ansible_core-2.21.5/lib/ansible/module_utils/ansible_release.py 
2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/module_utils/ansible_release.py 
2026-11-02 18:32:18.000000000 +0100
@@ -17,6 +17,6 @@
 
 from __future__ import annotations
 
-__version__ = '2.21.5'
+__version__ = '2.21.6'
 __author__ = 'Ansible, Inc.'
 __codename__ = "The Rain Song"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/module_utils/urls.py 
new/ansible_core-2.21.6/lib/ansible/module_utils/urls.py
--- old/ansible_core-2.21.5/lib/ansible/module_utils/urls.py    2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/module_utils/urls.py    2026-11-02 
18:32:18.000000000 +0100
@@ -47,6 +47,7 @@
 import tempfile
 import traceback
 import types  # pylint: disable=unused-import
+import unicodedata
 import urllib.error
 import urllib.request
 
@@ -322,11 +323,18 @@
     Safely display a url by masking confidential data
     from a string or the result from urlparse/split
     """
-    if (parsed_url := urlparse(url)) and not parsed_url.username and not 
parsed_url.password:
-        return url
+    mask = '****'
+    try:
+        if (parsed_url := urlparse(url)) and not parsed_url.username and not 
parsed_url.password:
+            return url
+    except Exception as e:
+        msg = 'mask_url could not parse the url provided'
+        # skip replace on unicode issues, specially since they can be subsets 
of url.
+        if unicodedata.is_normalized('NFKC', url):
+            msg = f'{msg}: {str(e).replace(url, mask)}'
+        raise ValueError(msg)
 
     netloc: str
-    mask = '****'
     if parsed_url.password:
         netloc = 
parsed_url.netloc.replace(f'{parsed_url.username}:{parsed_url.password}@', 
f'{mask}:{mask}@')
     else:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/modules/git.py 
new/ansible_core-2.21.6/lib/ansible/modules/git.py
--- old/ansible_core-2.21.5/lib/ansible/modules/git.py  2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/modules/git.py  2026-11-02 
18:32:18.000000000 +0100
@@ -379,14 +379,14 @@
                 module.fail_json(msg="Unable to read %s" % headfile)
         if rawdata:
             try:
-                rawdata = rawdata.replace('refs/remotes/%s' % remote, '', 1)
+                rawdata = rawdata.replace(f'refs/remotes/{remote}', '', 1)
                 refparts = rawdata.split(' ')
                 newref = refparts[-1]
                 nrefparts = newref.split('/', 2)
                 res = nrefparts[-1].rstrip('\n')
             except Exception:
                 if fail_on_error and module:
-                    module.fail_json(msg="Unable to split head from '%s'" % 
rawdata)
+                    module.fail_json(msg=f"Unable to split head from 
'{rawdata}'")
     return res
 
 
@@ -406,7 +406,7 @@
     params = []
 
     # run a bad submodule command to get valid params
-    cmd = "%s submodule update --help" % (git_path)
+    cmd = [git_path, 'submodule', 'update', '--help']
     rc, stdout, stderr = module.run_command(cmd, cwd=cwd)
     lines = stderr.split('\n')
     update_line = None
@@ -519,7 +519,7 @@
 def get_version(module, git_path, dest, ref="HEAD"):
     """ samples the version of the git repo """
 
-    cmd = "%s rev-parse %s" % (git_path, ref)
+    cmd = [git_path, "rev-parse", ref]
     rc, stdout, stderr = module.run_command(cmd, cwd=dest)
     sha = to_native(stdout).rstrip('\n')
     return sha
@@ -639,7 +639,7 @@
             # git before 1.7.5 doesn't have separate-git-dir argument, do 
fallback
             needs_separate_git_dir_fallback = True
         else:
-            cmd.append('--separate-git-dir=%s' % separate_git_dir)
+            cmd.append(f'--separate-git-dir={separate_git_dir}')
 
     cmd.extend([repo, dest])
     module.run_command(cmd, check_rc=True, cwd=dest_dirname)
@@ -664,7 +664,7 @@
     if bare:
         return False
 
-    cmd = "%s status --porcelain" % (git_path)
+    cmd = [git_path, "status", "--porcelain"]
     rc, stdout, stderr = module.run_command(cmd, cwd=dest)
     lines = stdout.splitlines()
     lines = list(filter(lambda c: not re.search('^\\?\\?.*$', c), lines))
@@ -678,7 +678,7 @@
     Discards any changes to tracked files in working
     tree since that commit.
     """
-    cmd = "%s reset --hard HEAD" % (git_path,)
+    cmd = [git_path, "reset", "--hard", "HEAD"]
     return module.run_command(cmd, check_rc=True, cwd=dest)
 
 
@@ -690,7 +690,7 @@
         # Ensure we have the object we are referring to during git diff !
         git_version_used = git_version(git_path, module)
         fetch(git_path, module, repo, dest, after, remote, depth, bare, 
refspec, git_version_used, force)
-        cmd = '%s diff %s %s' % (git_path, before, after)
+        cmd = [git_path, 'diff', before, after]
         (rc, out, err) = module.run_command(cmd, cwd=dest)
         if rc == 0 and out:
             return {'prepared': out}
@@ -721,6 +721,7 @@
     cloning = False
     cwd = None
     tag = False
+    cmd = [git_path, 'ls-remote', remote]
     if remote == module.params['repo']:
         cloning = True
     elif remote == 'file://' + os.path.expanduser(module.params['repo']):
@@ -730,15 +731,15 @@
     if version == 'HEAD':
         if cloning:
             # cloning the repo, just get the remote's HEAD version
-            cmd = '%s ls-remote %s -h HEAD' % (git_path, remote)
+            cmd.extend(['-h', 'HEAD'])
         else:
             head_branch = get_head_branch(git_path, module, dest, remote, bare)
-            cmd = '%s ls-remote %s -h refs/heads/%s' % (git_path, remote, 
head_branch)
+            cmd.extend(['-h', '--', shlex.quote(f'refs/heads/{head_branch}')])
     elif is_remote_branch(git_path, module, dest, remote, version):
-        cmd = '%s ls-remote %s -h refs/heads/%s' % (git_path, remote, version)
+        cmd.extend(['-h', f'refs/heads/{version}'])
     elif is_remote_tag(git_path, module, dest, remote, version):
         tag = True
-        cmd = '%s ls-remote %s -t refs/tags/%s*' % (git_path, remote, version)
+        cmd.extend(['-t', f'refs/tags/{version}*'])
     else:
         # Appears to be a sha hash. Checking requires special action
         rev = get_sha_hash(module, git_path, remote, version, cwd)
@@ -765,7 +766,7 @@
 
 
 def is_remote_tag(git_path, module, dest, remote, version):
-    cmd = '%s ls-remote %s -t refs/tags/%s' % (git_path, remote, version)
+    cmd = [git_path, 'ls-remote', remote, '-t', f'refs/tags/{version}']
     (rc, out, err) = module.run_command(cmd, check_rc=True, cwd=dest)
     if to_native(version, errors='surrogate_or_strict') in out:
         return True
@@ -775,10 +776,10 @@
 
 def get_branches(git_path, module, dest):
     branches = []
-    cmd = '%s branch --no-color -a' % (git_path,)
+    cmd = [git_path, 'branch', '--no-color', '-a']
     (rc, out, err) = module.run_command(cmd, cwd=dest)
     if rc != 0:
-        module.fail_json(msg="Could not determine branch data - received %s" % 
out, stdout=out, stderr=err)
+        module.fail_json(msg=f"Could not determine branch data - received 
{out}", stdout=out, stderr=err)
     for line in out.split('\n'):
         if line.strip():
             branches.append(line.strip())
@@ -790,7 +791,7 @@
     cmd = [git_path, 'for-each-ref', 'refs/tags/', '--format', 
'%(objecttype):%(refname:short)']
     (rc, out, err) = module.run_command(cmd, cwd=dest)
     if rc != 0:
-        module.fail_json(msg="Could not determine tag data - received %s" % 
out, stdout=out, stderr=err)
+        module.fail_json(msg=f"Could not determine tag data - received {out}", 
stdout=out, stderr=err)
     for line in to_native(out).split('\n'):
         if line.strip():
             tagtype, tagname = line.strip().split(':')
@@ -800,7 +801,7 @@
 
 
 def is_remote_branch(git_path, module, dest, remote, version):
-    cmd = '%s ls-remote %s -h refs/heads/%s' % (git_path, remote, version)
+    cmd = [git_path, 'ls-remote', remote, '-h', f'refs/heads/{version}']
     (rc, out, err) = module.run_command(cmd, check_rc=True, cwd=dest)
     if to_native(version, errors='surrogate_or_strict') in out:
         return True
@@ -810,13 +811,7 @@
 
 def is_local_branch(git_path, module, dest, branch):
     branches = get_branches(git_path, module, dest)
-    lbranch = '%s' % branch
-    if lbranch in branches:
-        return True
-    elif '* %s' % branch in branches:
-        return True
-    else:
-        return False
+    return f'{branch}' in branches or f'* {branch}' in branches
 
 
 def is_not_a_branch(git_path, module, dest):
@@ -878,6 +873,11 @@
     if is_not_a_branch(git_path, module, dest):
         headfile = os.path.join(repo_path, 'refs', 'remotes', remote, 'HEAD')
     branch = head_splitter(headfile, remote, module=module, fail_on_error=True)
+
+    if branch.startswith('-'):
+        # prevent branch names that can be confused as options
+        module.fail_json(msg=f'Invalid branch name ({branch}) from {headfile} 
in {remote}')
+
     return branch
 
 
@@ -1002,13 +1002,13 @@
             after = {}
             for submodule in begin:
                 branch = get_submodule_branch(git_path, module, dest, 
submodule)
-                version_ref = f'{remote}/{branch}' if branch != 'HEAD' else 
'HEAD'
+                version_ref = 'HEAD' if branch == 'HEAD' else 
shlex.quote(f'{remote}/{branch}')
                 submodule_path = os.path.join(dest, submodule)
                 cmd = [git_path, 'rev-parse', version_ref]
                 (rc, out, err) = module.run_command(cmd, cwd=submodule_path)
                 if rc != 0:
                     module.fail_json(
-                        msg='Unable to determine hash of submodule %s at %s' % 
(submodule, version_ref),
+                        msg=f'Unable to determine hash of submodule 
{submodule} at {version_ref}',
                         stdout=out, stderr=err, rc=rc)
                 after[submodule] = out.strip()
             if begin != after:
@@ -1056,48 +1056,52 @@
     Can't use git remote set-branches, as it is not available in git 1.7.1 
(centos6)
     """
 
-    branchref = "+refs/heads/%s:refs/heads/%s" % (version, version)
-    branchref += ' +refs/heads/%s:refs/remotes/%s/%s' % (version, remote, 
version)
-    cmd = "%s fetch --depth=%s %s %s" % (git_path, depth, remote, branchref)
+    cmd = [
+        git_path,
+        "fetch",
+        f"--depth={depth}",
+        remote,
+        f"+refs/heads/{version}:refs/heads/{version}",
+        f"+refs/heads/{version}:refs/remotes/{remote}/{version}",
+    ]
     (rc, out, err) = module.run_command(cmd, cwd=dest)
     if rc != 0:
-        module.fail_json(msg="Failed to fetch branch from remote: %s" % 
version, stdout=out, stderr=err, rc=rc)
+        module.fail_json(msg=f"Failed to fetch branch from remote: {version}", 
stdout=out, stderr=err, rc=rc)
 
 
 def switch_version(git_path, module, dest, remote, version, verify_commit, 
depth, gpg_allowlist):
     cmd = ''
     if version == 'HEAD':
         branch = get_head_branch(git_path, module, dest, remote)
-        (rc, out, err) = module.run_command("%s checkout --force %s" % 
(git_path, branch), cwd=dest)
+        (rc, out, err) = module.run_command([git_path, "checkout", "--force", 
branch], cwd=dest)
         if rc != 0:
-            module.fail_json(msg="Failed to checkout branch %s" % branch,
-                             stdout=out, stderr=err, rc=rc)
-        cmd = "%s reset --hard %s/%s --" % (git_path, remote, branch)
+            module.fail_json(msg=f"Failed to checkout branch {branch} HEAD", 
stdout=out, stderr=err, rc=rc)
+        cmd = [git_path, "reset", "--hard", shlex.quote("/".join([remote, 
branch]))]
     else:
         # FIXME check for local_branch first, should have been fetched already
         if is_remote_branch(git_path, module, dest, remote, version):
+            rv = "/".join([remote, version])
             if depth and not is_local_branch(git_path, module, dest, version):
                 # git clone --depth implies --single-branch, which makes
                 # the checkout fail if the version changes
                 # fetch the remote branch, to be able to check it out next
                 set_remote_branch(git_path, module, dest, remote, version, 
depth)
             if not is_local_branch(git_path, module, dest, version):
-                cmd = "%s checkout --track -b %s %s/%s" % (git_path, version, 
remote, version)
+                cmd = [git_path, "checkout", "--track", "-b", version, rv]
             else:
-                (rc, out, err) = module.run_command("%s checkout --force %s" % 
(git_path, version), cwd=dest)
+                (rc, out, err) = module.run_command([git_path, "checkout", 
"--force", version], cwd=dest)
                 if rc != 0:
                     module.fail_json(msg="Failed to checkout branch %s" % 
version, stdout=out, stderr=err, rc=rc)
-                cmd = "%s reset --hard %s/%s" % (git_path, remote, version)
+                cmd = [git_path, "reset", "--hard", rv]
         else:
-            cmd = "%s checkout --force %s" % (git_path, version)
+            cmd = [git_path, "checkout", "--force", version]
     (rc, out1, err1) = module.run_command(cmd, cwd=dest)
     if rc != 0:
-        if version != 'HEAD':
-            module.fail_json(msg="Failed to checkout %s" % (version),
-                             stdout=out1, stderr=err1, rc=rc, cmd=cmd)
+        if version == 'HEAD':
+            msg = f"Failed to checkout branch {branch}"
         else:
-            module.fail_json(msg="Failed to checkout branch %s" % (branch),
-                             stdout=out1, stderr=err1, rc=rc, cmd=cmd)
+            msg = f"Failed to checkout {version}"
+        module.fail_json(msg=msg, stdout=out1, stderr=err1, rc=rc, cmd=cmd)
 
     if verify_commit:
         verify_commit_sign(git_path, module, dest, version, gpg_allowlist)
@@ -1110,9 +1114,9 @@
         git_sub = "verify-tag"
     else:
         git_sub = "verify-commit"
-    cmd = "%s %s %s" % (git_path, git_sub, version)
+    cmd = [git_path, git_sub, version]
     if gpg_allowlist:
-        cmd += " --raw"
+        cmd.append("--raw")
     (rc, out, err) = module.run_command(cmd, cwd=dest)
     if rc != 0:
         module.fail_json(msg='Failed to verify GPG signature of commit/tag 
"%s"' % version, stdout=out, stderr=err, rc=rc)
@@ -1141,7 +1145,7 @@
 
 def git_version(git_path, module):
     """return the installed version of git"""
-    cmd = "%s --version" % git_path
+    cmd = [git_path, "--version"]
     (rc, out, err) = module.run_command(cmd)
     if rc != 0:
         # one could fail_json here, but the version info is not that important,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/modules/user.py 
new/ansible_core-2.21.6/lib/ansible/modules/user.py
--- old/ansible_core-2.21.5/lib/ansible/modules/user.py 2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/modules/user.py 2026-11-02 
18:32:18.000000000 +0100
@@ -3132,7 +3132,7 @@
         - remove_user()
         - modify_user()
     """
-    def _build_password_string(self, current_password=None):
+    def _build_password_string(self, current_password: str | None = None) -> 
str:
         """
         Build the appropriate password string based on the current password and
         module parameters.
@@ -3140,27 +3140,26 @@
         This method will return '*' at a minimum to avoid creating an enabled
         account with no password.
         """
-        lock = LOCK_INDICATOR if self.password_lock else ''
-
-        # Order of precedence when choosing the password:
-        #   1. password from module parameters
-        #   2. current password
-        #   3. string to enable the account but without a password
-        password = '*'
         if self.password is not None:
             password = self.password
         elif current_password:
             password = current_password
-            if current_password == LOCK_INDICATOR:
-                # Special handling when the password is only a '!' to avoid
-                # unnecessary changes to the password to values like '!!' or 
'!*'.
-                lock = ''
-            elif current_password.startswith(LOCK_INDICATOR):
-                # Preserve the existing password but unlock the account even if
-                # no password hash was provided in the module parameters.
-                password = current_password.lstrip(LOCK_INDICATOR)
+        else:
+            password = '*'
+
+        currently_locked = bool(current_password) and 
current_password.startswith(LOCK_INDICATOR)
+
+        if self.password_lock is True:
+            if not password.startswith(LOCK_INDICATOR):
+                password = f'{LOCK_INDICATOR}{password}'
+        elif self.password_lock is False:
+            # Leave a lone '!' as-is so unlocking does not produce an empty 
field.
+            if password.startswith(LOCK_INDICATOR) and password != 
LOCK_INDICATOR:
+                password = password.removeprefix(LOCK_INDICATOR)
+        elif currently_locked and not password.startswith(LOCK_INDICATOR):
+            password = f'{LOCK_INDICATOR}{password}'
 
-        return f'{lock}{password}'
+        return password
 
     def create_user(self):
         cmd = [self.module.get_bin_path('adduser', True)]
@@ -3296,11 +3295,9 @@
 
         # Manage password
         current_password = to_native(user_info[1])
-        new_password = self._build_password_string(current_password)
-        if self.update_password == 'always':
-            lock_status_mismatch = self.password_lock and not 
current_password.startswith('!')
-            password_changed = new_password != current_password
-            if lock_status_mismatch or password_changed:
+        if self.password is not None or self.password_lock is not None:
+            new_password = self._build_password_string(current_password)
+            if self.update_password == 'always' and new_password != 
current_password:
                 cmd = [self.module.get_bin_path('chpasswd', True), 
'--encrypted']
                 data = f'{self.name}:{new_password}'
                 rc, out, err = self.execute_command(cmd, data=data)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/lib/ansible/plugins/become/sudo.py 
new/ansible_core-2.21.6/lib/ansible/plugins/become/sudo.py
--- old/ansible_core-2.21.5/lib/ansible/plugins/become/sudo.py  2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/plugins/become/sudo.py  2026-11-02 
18:32:18.000000000 +0100
@@ -101,6 +101,17 @@
     fail = ('Sorry, try again.',)
     missing = ('Sorry, a password is required to run sudo', 'sudo: a password 
is required')
 
+    def check_password_prompt(self, b_output: bytes) -> bool:
+        # try GNU sudo first
+        matched = super().check_password_prompt(b_output)
+        if not matched:
+            # try sudo-rs, which is not backwards compatible and does i18n
+            # Sample: '[sudo: [sudo via ansible, 
key=csfhjsshkagnlthoutcsjownopciscow] password:] Password:'
+            sudo_rs = 
re.compile(fr"\[sudo:\s+{re.escape(self.prompt)}\]\s+\S.+\Z".encode())
+            # using match as the line MUST start with this
+            matched = any(sudo_rs.match(l) for l in b_output.splitlines())
+        return matched
+
     def build_become_command(self, cmd, shell):
         super(BecomeModule, self).build_become_command(cmd, shell)
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/release.py 
new/ansible_core-2.21.6/lib/ansible/release.py
--- old/ansible_core-2.21.5/lib/ansible/release.py      2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/release.py      2026-11-02 
18:32:18.000000000 +0100
@@ -17,6 +17,6 @@
 
 from __future__ import annotations
 
-__version__ = '2.21.5'
+__version__ = '2.21.6'
 __author__ = 'Ansible, Inc.'
 __codename__ = "The Rain Song"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/lib/ansible/vars/manager.py 
new/ansible_core-2.21.6/lib/ansible/vars/manager.py
--- old/ansible_core-2.21.5/lib/ansible/vars/manager.py 2026-10-05 
17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/lib/ansible/vars/manager.py 2026-11-02 
18:32:18.000000000 +0100
@@ -580,8 +580,8 @@
         try:
             host_cache = self._fact_cache.get(host)
         except KeyError:
-            # We get to set this as new
-            host_cache = facts
+            # We get to set this as new. Copy to ensure shared facts from 
run_once can be updated individually.
+            host_cache = dict(facts)
         else:
             if not isinstance(host_cache, MutableMapping):
                 raise TypeError('The object retrieved for {0} must be a 
MutableMapping but was'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/ansible_core-2.21.5/pyproject.toml 
new/ansible_core-2.21.6/pyproject.toml
--- old/ansible_core-2.21.5/pyproject.toml      2026-10-05 17:06:50.000000000 
+0200
+++ new/ansible_core-2.21.6/pyproject.toml      2026-11-02 18:32:18.000000000 
+0100
@@ -1,5 +1,5 @@
 [build-system]
-requires = ["setuptools >= 77.0.3, <= 84.0.0"]  # lower bound to support 
license/license-files (PEP 639), upper bound for latest version tested at 
release
+requires = ["setuptools >= 77.0.3, <= 84.2.1"]  # lower bound to support 
license/license-files (PEP 639), upper bound for latest version tested at 
release
 build-backend = "setuptools.build_meta"
 
 [project]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/files/build_bad_tar.py
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/files/build_bad_tar.py
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/files/build_bad_tar.py
   2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/files/build_bad_tar.py
   2026-11-02 18:32:18.000000000 +0100
@@ -5,11 +5,11 @@
 
 from __future__ import annotations
 
+import argparse
 import hashlib
 import io
 import json
 import os
-import sys
 import tarfile
 from ansible.module_utils.common.file import S_IRWXU_RXG_RXO
 
@@ -44,9 +44,23 @@
 }
 
 
-def add_file(tar_file, filename, b_content, update_files=True):
+def calculate_target_filename(symlink_path, linkname):
+    """The target is relative to the symlink."""
+    symlink_dir = os.path.dirname(symlink_path)
+    if symlink_dir:
+        # tarfile will extract the linkname as {{ symlink_dir }}/{{ linkname 
}}, even if linkname is an absolute path
+        return "/".join([symlink_dir, linkname])
+    return linkname
+
+
+def add_file(tar_file, filename, b_content, update_files=True, 
symlink_linkname=None):
     tar_info = tarfile.TarInfo(filename)
-    tar_info.size = len(b_content)
+    if symlink_linkname is not None:
+        add_file(tar_file, calculate_target_filename(filename, 
symlink_linkname), b"", update_files=False)
+        tar_info.type = tarfile.SYMTYPE
+        tar_info.linkname = symlink_linkname
+    else:
+        tar_info.size = len(b_content)
     tar_info.mode = S_IRWXU_RXG_RXO
     tar_file.addfile(tarinfo=tar_info, fileobj=io.BytesIO(b_content))
 
@@ -58,14 +72,76 @@
             'name': filename,
             'ftype': 'file',
             'chksum_type': 'sha256',
-            'chksum_sha256': sha256.hexdigest(),
+            'chksum_sha256': sha256.hexdigest() if symlink_linkname is None 
else None,
             'format': 1
         })
 
 
-collection_tar = os.path.join(sys.argv[1], 'suspicious-test-1.0.0.tar.gz')
+def add_dir(tar_file, dir_name, symlink_linkname=None, update_files=True):
+    if symlink_linkname:
+        add_dir(tar_file, symlink_linkname, update_files=False)
+
+    tar_info = tarfile.TarInfo(dir_name)
+
+    if symlink_linkname is None:
+        tar_info.type = tarfile.DIRTYPE
+    else:
+        tar_info.type = tarfile.SYMTYPE
+        tar_info.linkname = symlink_linkname
+
+    tar_info.mode = 0o777
+    tar_file.addfile(tar_info)
+
+    if not update_files:
+        return
+
+    files['files'].append({
+        'name': dir_name,
+        'ftype': 'dir',
+        'chksum_type': 'sha256',
+        'chksum_sha256': None,
+        'format': 1,
+    })
+
+
+parser = argparse.ArgumentParser(description="Test suspiciously crafted 
collection artifacts.")
+parser.add_argument("dest", help="The destination directory for the collection 
artifact.")
+
+mutually_exclusive_group = parser.add_mutually_exclusive_group()
+mutually_exclusive_group.add_argument(
+    "--file", help="A file to add to the collection artifact.",
+)
+mutually_exclusive_group.add_argument(
+    "--dir", help="A directory to add to the collection artifact.",
+)
+mutually_exclusive_group.add_argument(
+    "--symlink-linkname", help="Add a symlink linkname to the collection 
artifact.",
+)
+parser.add_argument(
+    "--symlink", help="The name of the symlink to use with --symlink-linkname. 
Defaults to 'symlink'.",
+)
+
+
+args = parser.parse_args()
+
+collection_tar = os.path.join(args.dest, 'suspicious-test-1.0.0.tar.gz')
 with tarfile.open(collection_tar, mode='w:gz') as tar_file:
-    add_file(tar_file, '../../outside.sh', b"#!/usr/bin/env bash\necho \"you 
got pwned\"")
+    filename = args.file
+    dirname = args.dir
+
+    if args.symlink and not args.symlink_linkname:
+        parser.error("--symlink and --symlink-linkname must be used together")
+
+    if args.symlink_linkname:
+        if args.symlink_linkname.endswith(os.path.sep):
+            dirname = args.symlink or "symlink"
+        else:
+            filename = args.symlink or "symlink"
+
+    if dirname:
+        add_dir(tar_file, dirname, symlink_linkname=args.symlink_linkname)
+    elif filename:
+        add_file(tar_file, filename, b"#!/usr/bin/env bash\necho \"you got 
pwned\"", symlink_linkname=args.symlink_linkname)
 
     b_files = json.dumps(files).encode('utf-8')
     b_files_hash = hashlib.sha256()
@@ -73,12 +149,3 @@
     manifest['file_manifest_file']['chksum_sha256'] = b_files_hash.hexdigest()
     add_file(tar_file, 'FILES.json', b_files)
     add_file(tar_file, 'MANIFEST.json', json.dumps(manifest).encode('utf-8'))
-
-    b_manifest = json.dumps(manifest).encode('utf-8')
-
-    for name, b in [('MANIFEST.json', b_manifest), ('FILES.json', b_files)]:
-        b_io = io.BytesIO(b)
-        tar_info = tarfile.TarInfo(name)
-        tar_info.size = len(b)
-        tar_info.mode = 0o0644
-        tar_file.addfile(tarinfo=tar_info, fileobj=b_io)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/build_and_install_tarball.yml
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/build_and_install_tarball.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/build_and_install_tarball.yml
      1970-01-01 01:00:00.000000000 +0100
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/build_and_install_tarball.yml
      2026-11-02 18:32:18.000000000 +0100
@@ -0,0 +1,37 @@
+- name: Test a specially built tarball
+  debug:
+    msg: "Creating tarball with arguments: {{ tarfile_arguments }}"
+
+- name: Create tarball
+  script: build_bad_tar.py {{ galaxy_dir | quote }} {{ tarfile_arguments }}
+
+- name: Try to install the tarball
+  command: ansible-galaxy collection install '{{ galaxy_dir 
}}/suspicious-test-1.0.0.tar.gz' {{ galaxy_verbosity }}
+  register: fail_bad_tar
+  ignore_errors: true
+  environment:
+    ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}/ansible_collections'
+
+- name: Get the result of the install attempt
+  stat:
+    path: '{{ galaxy_dir }}/ansible_collections/suspicious'
+  register: fail_bad_tar_actual
+
+- name: Remove collection if it was installed
+  file:
+    path: '{{ galaxy_dir }}/ansible_collections'
+    state: absent
+  when: cleanup | default(True)
+
+- name: Assert the expected result
+  assert:
+    fail_msg: "{{ rc_msg }}{{ stderr_msg }}{{ stdout_msg }}"
+    that:
+      - fail_bad_tar.rc == expected_rc
+      - fail_bad_tar.stderr is search(expected_error)
+      - fail_bad_tar.stdout is search(expected_stdout)
+      - (expected_rc == 0) | ternary(fail_bad_tar_actual.stat.exists, not 
fail_bad_tar_actual.stat.exists)
+  vars:
+    rc_msg: "Expected rc: {{ expected_rc }}"
+    stderr_msg: "{{ (expected_error == '') | ternary('', ', error: ' ~ 
expected_error) }}"
+    stdout_msg: "{{ (expected_stdout == '') | ternary('', ', stdout: ' ~ 
expected_stdout) }}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
        2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
        2026-11-02 18:32:18.000000000 +0100
@@ -297,26 +297,6 @@
         - "{{ galaxy_dir }}/scratch/tmp_parent/"
         - "{{ galaxy_dir }}/tmp_parent-name-1.0.0.tar.gz"
 
-- name: setup bad tarball - {{ test_id }}
-  script: build_bad_tar.py {{ galaxy_dir | quote }}
-
-- name: fail to install a collection from a bad tarball - {{ test_id }}
-  command: ansible-galaxy collection install '{{ galaxy_dir 
}}/suspicious-test-1.0.0.tar.gz' {{ galaxy_verbosity }}
-  register: fail_bad_tar
-  failed_when: fail_bad_tar.rc != 1 and "Cannot extract tar entry 
'../../outside.sh' as it will be placed outside the collection directory" not 
in fail_bad_tar.stderr
-  environment:
-    ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}/ansible_collections'
-
-- name: get result of failed collection install - {{ test_id }}
-  stat:
-    path: '{{ galaxy_dir }}/ansible_collections\suspicious'
-  register: fail_bad_tar_actual
-
-- name: assert result of failed collection install - {{ test_id }}
-  assert:
-    that:
-    - not fail_bad_tar_actual.stat.exists
-
 - name: install a collection from a URI - {{ test_id }}
   command: ansible-galaxy collection install {{ amanda 
}}/artifacts/namespace4-name-1.0.0.tar.gz {{ galaxy_verbosity }}
   register: install_uri
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/install_tarball.yml
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/install_tarball.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/install_tarball.yml
        1970-01-01 01:00:00.000000000 +0100
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/install_tarball.yml
        2026-11-02 18:32:18.000000000 +0100
@@ -0,0 +1,97 @@
+- name: Test unsafe tarball extraction
+  vars:
+    unsafe_rel_path: '../../outside'
+    unsafe_abs_path: '{{ galaxy_dir 
}}/ansible_collections/suspicious/test/../../outside'
+    safe_linkname: '{{ galaxy_dir 
}}/ansible_collections/suspicious/test/inside'
+    expected_rc: 1
+    expected_stdout: ""
+    tarfile_arguments: "{{ item }}"
+  block:
+    - name: Test unsafe file extraction
+      include_tasks: build_and_install_tarball.yml
+      loop:
+        - "--file '{{ unsafe_rel_path }}.sh'"
+        - "--file '{{ unsafe_abs_path }}.sh'"
+        - "--symlink '{{ unsafe_rel_path }}.sh' --symlink-linkname '{{ 
safe_linkname }}.sh'"
+        - "--symlink '{{ unsafe_abs_path }}.sh' --symlink-linkname '{{ 
safe_linkname }}.sh'"
+      vars:
+        expected_error: "Cannot extract tar entry"
+
+    - name: Test unsafe directory extraction
+      include_tasks: build_and_install_tarball.yml
+      loop:
+        - "--dir '{{ unsafe_rel_path }}'"
+        - "--dir '{{ unsafe_abs_path }}'"
+        - "--symlink '{{ unsafe_rel_path }}' --symlink-linkname '{{ 
safe_linkname }}/'"
+        - "--symlink '{{ unsafe_abs_path }}' --symlink-linkname '{{ 
safe_linkname }}/'"
+      vars:
+        expected_error: "Cannot extract directory"
+
+    - name: Test unsafe symlink linkname
+      include_tasks: build_and_install_tarball.yml
+      loop:
+        - "--symlink-linkname '{{ unsafe_rel_path }}.sh'"
+        - "--symlink-linkname '{{ unsafe_abs_path }}.sh'"
+        - "--symlink-linkname '{{ unsafe_rel_path }}/'"
+        - "--symlink-linkname '{{ unsafe_abs_path }}/'"
+        - "--symlink 'some_directory/symlink' --symlink-linkname 
'safe/../../../unsafe'"
+      vars:
+        expected_error: >-
+          Cannot extract symlink '.*symlink' in collection: path points to 
location outside of collection
+
+- name: Test safe tarball extraction
+  vars:
+    expected_rc: 0
+    expected_error: ""
+    expected_stdout: "suspicious\\.test:1\\.0\\.0 was installed successfully"
+    cleanup: False
+  block:
+    - name: Test successful directory extraction
+      include_tasks: build_and_install_tarball.yml
+      loop:
+        - "--dir 'safe/'"
+        - "--dir 'safe'"
+      loop_control:
+        loop_var: tarfile_arguments
+      vars:
+        cleanup: True
+
+    - name: Test successful relative linkname extraction
+      include_tasks: build_and_install_tarball.yml
+      vars:
+        tarfile_arguments: "--symlink 'some_directory/symlink' 
--symlink-linkname '../safe'"
+
+    - &stat_symlink
+      name: Check extracted the symlink exists
+      stat:
+        path: "{{ galaxy_dir 
}}/ansible_collections/suspicious/test/some_directory/symlink"
+      register: expected_symlink
+
+    - &realpath_symlink
+      name: Check extracted symlink is not broken
+      command: "realpath {{ galaxy_dir 
}}/ansible_collections/suspicious/test/some_directory/symlink"
+      ignore_errors: True
+      register: realpath_result
+
+    - &assert_symlink
+      assert:
+        that:
+          - expected_symlink.stat.exists
+          - realpath_result is not failed
+          - expected_symlink.stat.lnk_source == realpath_result.stdout == 
galaxy_dir ~ '/ansible_collections/suspicious/test/safe'
+
+    - &cleanup
+      name: Remove installed collection
+      file:
+        path: "{{ galaxy_dir }}/ansible_collections"
+        state: absent
+
+    - name: Test successful absolute linkname extraction
+      include_tasks: build_and_install_tarball.yml
+      vars:
+        tarfile_arguments: "--symlink some_directory/symlink 
--symlink-linkname '{{ galaxy_dir }}/ansible_collections/suspicious/test/safe'"
+
+    - <<: *stat_symlink
+    - <<: *realpath_symlink
+    - <<: *assert_symlink
+    - <<: *cleanup
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/main.yml
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/main.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-collection/tasks/main.yml
   2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-collection/tasks/main.yml
   2026-11-02 18:32:18.000000000 +0100
@@ -57,6 +57,9 @@
       environment:
         ANSIBLE_CONFIG: '{{ galaxy_dir }}/ansible.cfg'
 
+- name: run ansible-galaxy collection tarball extraction tests
+  include_tasks: install_tarball.yml
+
 - name: run ansible-galaxy collection publish tests for {{ test_name }}
   include_tasks: publish.yml
   args:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-role/tasks/main.yml
 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-role/tasks/main.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-galaxy-role/tasks/main.yml
 2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-galaxy-role/tasks/main.yml
 2026-11-02 18:32:18.000000000 +0100
@@ -63,11 +63,47 @@
   register: invalid
 
 - assert:
-    that: "invalid.rc != 0"
+    that:
+      - invalid.rc != 0
+      - 'invalid.stderr is search("Invalid linkname for tarfile member: path 
(:?/|/root/invalid) is not a subpath of the role")'
 
 - name: Uninstall invalid role
   command: ansible-galaxy role remove invalid-testrole
 
+- name: Test invalid role requirement name
+  vars:
+    abs_name: "{{ remote_tmp_dir }}/unsafe"
+    install_msg: "{{ abs_name }} was installed successfully"
+    external_msg: "The role name '{{ abs_name }}' resolves to a path outside 
of the configured role directory"
+  block:
+    - name: Create invalid requirements file
+      copy:
+        dest: invalid-requirements.yml
+        content: |-
+          - src: "{{ remote_tmp_dir }}/valid-role.tar"
+            name: "{{ abs_name }}"
+
+    - name: Test installing the role
+      command: ansible-galaxy install -r invalid-requirements.yml
+      register: fail_external
+      ignore_errors: True
+
+    - assert:
+        that:
+          - fail_external is failed
+          - fail_external.stderr is search(external_msg)
+          - fail_external.stdout is not search(install_msg)
+
+- name: Test --ignore-errors skips unsatisfiable invalid requirement name
+  command: ansible-galaxy install {{ remote_tmp_dir }}/roles/../valid-role.tar 
--roles-path {{ remote_tmp_dir }}/roles --ignore-errors
+  register: warn_external
+
+- assert:
+    that:
+      - warn_external is success
+      - warn_external.stderr is search("The role name '.*' resolves to a path 
outside of the configured role directory")
+      - warn_external.stdout is not search(".* was installed successfully")
+
 - import_tasks: dir-traversal.yml
 - import_tasks: valid-role-symlinks.yml
 - import_tasks: git-config-injection.yml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/ansible-test-container/runme.py
 
new/ansible_core-2.21.6/test/integration/targets/ansible-test-container/runme.py
--- 
old/ansible_core-2.21.5/test/integration/targets/ansible-test-container/runme.py
    2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/ansible-test-container/runme.py
    2026-11-02 18:32:18.000000000 +0100
@@ -883,6 +883,7 @@
                 dict(
                     user='root',
                     password=root_password,
+                    password_lock='false',
                 ),
             )
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/become_sudo/files/sudoshim.sh 
new/ansible_core-2.21.6/test/integration/targets/become_sudo/files/sudoshim.sh
--- 
old/ansible_core-2.21.5/test/integration/targets/become_sudo/files/sudoshim.sh  
    2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/become_sudo/files/sudoshim.sh  
    2026-11-02 18:32:18.000000000 +0100
@@ -11,6 +11,9 @@
     "--intermediate-user")
       intermediate_user_idx="${i}"
       ;;
+    "--set-lang")
+      lang_idx="${i}"
+      ;;
   esac
 done
 
@@ -19,4 +22,11 @@
 unset "args[intermediate_user_idx]"
 unset "args[intermediate_user_idx+1]"
 
+if [ -n "${lang_idx-}" ]; then
+  export LANG="${args[lang_idx+1]}"
+  export LC_ALL="${args[lang_idx+1]}"
+  unset "args[lang_idx]"
+  unset "args[lang_idx+1]"
+fi
+
 exec sudo -n -u "${intermediate_user_name}" sudo -k "${args[@]}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/become_sudo/tasks/main.yml 
new/ansible_core-2.21.6/test/integration/targets/become_sudo/tasks/main.yml
--- old/ansible_core-2.21.5/test/integration/targets/become_sudo/tasks/main.yml 
2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/test/integration/targets/become_sudo/tasks/main.yml 
2026-11-02 18:32:18.000000000 +0100
@@ -82,3 +82,42 @@
         that:
           - custom_flags.stdout.strip() == test_user_name
           - no_user.stdout.strip() == "root"
+
+- name: Test sudo with i18n, required for sudo-rs, only ubuntu
+  when:
+    - ansible_facts['system'] == 'Linux'
+    - ansible_facts['distribution'] == 'Ubuntu'
+  vars:
+    testlocales:
+      - ar_EG.UTF-8   # كَلِمَةُ الْسِّرِ
+      - fr_FR.UTF-8   # Mot de passe
+      - es_ES.UTF-8   # Contraseña
+      - de_DE.UTF-8   # Passwort
+      - ja_JP.UTF-8   # パスワード
+      - km_KH.UTF-8   # ពាក្យសម្ងាត់
+      - th_TH.UTF-8   # Sหัสผ่าน
+      - tr_TR.UTF-8   # Şifre
+      - uk_UA.UTF-8   # Пароль
+      - vi_VN.UTF-8   # Mật khẩu 
+  block:
+    - name: Setup the locales
+      become: yes
+      block:
+        - name: ensure debian locales package is available
+          apt:
+            name: locales
+            state: present
+
+        - name: install language packs (also generates locales)
+          apt:
+            name: "{{ testlocales | map( 'regex_replace', '^(\\w{2})_.+', 
'language-pack-\\1') | list }}"
+
+        - name: Actually test sudo with locales
+          become_method: sudo
+          become_user: '{{ target_user_name }}'
+          become_exe: /tmp/sudoshim.sh
+          become_flags: '{{ default_become_flags }} {{ intermediate_flags }} 
--set-lang "{{item}}"'
+          vars:
+            ansible_become_password: '{{ intermediate_user_password }}'
+          shell: 'whoami && echo "${LANG} is lang and ${LC_ALL} is lc_all"'
+          loop: '{{ testlocales }}'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/gathering_facts/test_run_once.yml
 
new/ansible_core-2.21.6/test/integration/targets/gathering_facts/test_run_once.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/gathering_facts/test_run_once.yml
  2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/gathering_facts/test_run_once.yml
  2026-11-02 18:32:18.000000000 +0100
@@ -30,3 +30,35 @@
           file:
             path: /etc/ansible/facts.d/uuid.fact
             state: absent
+
+# https://github.com/ansible/ansible/issues/87415 -- run_once broadcasts one
+# templated result object to every target host; a later per-host fact update
+# on one of them must not leak onto the others via a shared cached dict.
+- hosts: facthost3,facthost4
+  gather_facts: no
+  tasks:
+    - name: Gather initial facts with run_once
+      facts_one:
+      run_once: True
+
+    - name: Gather additional facts for one host
+      facts_two:
+      when: inventory_hostname == "facthost4"
+
+    - name: Assert the untouched host still has only its run_once facts
+      assert:
+        that:
+          - hostvars.facthost3.ansible_facts.factsone == 'from facts_one 
module'
+          - hostvars.facthost3.ansible_facts.common_fact == 'also from 
facts_one module'
+          - "hostvars.facthost3.ansible_facts.common_dict_fact == {'key_one': 
'from facts_one', 'key_two': 'from facts_one'}"
+          - "'factstwo' not in hostvars.facthost3.ansible_facts"
+        msg: "facthost3 was polluted by facthost4's own later fact update -- 
the run_once broadcast aliased a shared dict across hosts"
+
+    - name: Assert the updated host has its own additional facts
+      assert:
+        that:
+          - hostvars.facthost4.ansible_facts.factsone == 'from facts_one 
module'
+          - hostvars.facthost4.ansible_facts.factstwo == 'from facts_two 
module'
+          - hostvars.facthost4.ansible_facts.common_fact == 'also from 
facts_two module'
+          - "hostvars.facthost4.ansible_facts.common_dict_fact == {'key_two': 
'from facts_two', 'key_four': 'from facts_two'}"
+        msg: "facthost4 did not receive its own per-host fact update"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/integration/targets/user/tasks/test_password_lock.yml
 
new/ansible_core-2.21.6/test/integration/targets/user/tasks/test_password_lock.yml
--- 
old/ansible_core-2.21.5/test/integration/targets/user/tasks/test_password_lock.yml
  2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/integration/targets/user/tasks/test_password_lock.yml
  2026-11-02 18:32:18.000000000 +0100
@@ -77,6 +77,74 @@
           - password_lock_7 is changed
           - password_lock_8 is not changed
 
+    - name: Lock account then omit password_lock
+      user:
+        name: ansibulluser
+        password_lock: yes
+
+    - name: Modify user with neither password nor password_lock
+      user:
+        name: ansibulluser
+      register: password_lock_omit_1
+
+    - name: Modify user with neither password nor password_lock again
+      user:
+        name: ansibulluser
+      register: password_lock_omit_2
+
+    - name: Ensure omitted password_lock does not change the account
+      assert:
+        that:
+          - password_lock_omit_1 is not changed
+          - password_lock_omit_2 is not changed
+
+    - name: LINUX | Get shadow after omitting password_lock
+      getent:
+        database: shadow
+        key: ansibulluser
+      when: ansible_facts.system == 'Linux'
+
+    - name: LINUX | Ensure lock is preserved when password_lock is omitted
+      assert:
+        that:
+          - getent_shadow['ansibulluser'][0].startswith('!')
+      when: ansible_facts.system == 'Linux'
+
+    - name: Change password without password_lock on BusyBox
+      when: ansible_distribution == 'Alpine'
+      block:
+        - name: Set new password and lock account
+          user:
+            name: ansibulluser
+            password: 
"$y$jCT$ZiF3ZV39/maUl9Lzt2Hk80$Ih6bI4OXU52OnWWqt3T1BAmVn8eH.4qlcP.8/NOjGN5"  
#gitleaks:allow
+            password_lock: yes
+          register: password_lock_9
+
+        - name: Ensure lock marker is preserved on password change
+          assert:
+            that:
+              - password_lock_9.changed
+
+        - name: Modify user without password_lock
+          user:
+            name: ansibulluser
+          register: password_lock_10
+
+        - name: Ensure lock marker is preserved on password change
+          assert:
+            that:
+              - not password_lock_10.changed
+
+        - name: Get shadow after password change
+          getent:
+            database: shadow
+            key: ansibulluser
+
+        - name: Ensure lock marker is preserved when user is modified without 
password_lock
+          assert:
+            that:
+              - getent_shadow['ansibulluser'][0].startswith('!')
+
     - name: Lock account
       user:
         name: ansibulluser
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/lib/ansible_test/_data/completion/remote.txt 
new/ansible_core-2.21.6/test/lib/ansible_test/_data/completion/remote.txt
--- old/ansible_core-2.21.5/test/lib/ansible_test/_data/completion/remote.txt   
2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/test/lib/ansible_test/_data/completion/remote.txt   
2026-11-02 18:32:18.000000000 +0100
@@ -2,14 +2,14 @@
 alpine become=doas_sudo provider=aws arch=x86_64
 fedora/43 python=3.14 become=sudo provider=aws arch=x86_64 alias=fedora/latest
 fedora become=sudo provider=aws arch=x86_64
-freebsd/14.4 python=3.14 python_dir=/usr/local/bin become=su_sudo provider=aws 
arch=x86_64 alias=freebsd/14
-freebsd/15.0 python=3.12 python_dir=/usr/local/bin become=su_sudo provider=aws 
arch=x86_64 alias=freebsd/15,freebsd/latest
+freebsd/14.5 python=3.14 python_dir=/usr/local/bin become=su_sudo provider=aws 
arch=x86_64 alias=freebsd/14
+freebsd/15.1 python=3.14 python_dir=/usr/local/bin become=su_sudo provider=aws 
arch=x86_64 alias=freebsd/15,freebsd/latest
 freebsd python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
 macos/26.3 python=3.14 python_dir=/usr/local/bin become=sudo provider=mac 
arch=aarch64 alias=macos/26,macos/latest
 macos python_dir=/usr/local/bin become=sudo provider=mac arch=aarch64
 rhel/8.10 python=3.12 become=sudo provider=aws arch=x86_64 alias=rhel/8
-rhel/9.7 python=3.9,3.12 become=sudo provider=aws arch=x86_64 alias=rhel/9
-rhel/10.1 python=3.12 become=sudo provider=aws arch=x86_64 
alias=rhel/10,rhel/latest
+rhel/9.8 python=3.9,3.14 become=sudo provider=aws arch=x86_64 alias=rhel/9
+rhel/10.2 python=3.12,3.14 become=sudo provider=aws arch=x86_64 
alias=rhel/10,rhel/latest
 rhel become=sudo provider=aws arch=x86_64
 ubuntu/22.04 python=3.10 become=sudo provider=aws arch=x86_64
 ubuntu/24.04 python=3.12 become=sudo provider=aws arch=x86_64 
alias=ubuntu/latest
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/lib/ansible_test/_util/target/setup/bootstrap.sh 
new/ansible_core-2.21.6/test/lib/ansible_test/_util/target/setup/bootstrap.sh
--- 
old/ansible_core-2.21.5/test/lib/ansible_test/_util/target/setup/bootstrap.sh   
    2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/lib/ansible_test/_util/target/setup/bootstrap.sh   
    2026-11-02 18:32:18.000000000 +0100
@@ -297,12 +297,12 @@
         # Declare platform/python version combinations which do not have 
supporting OS packages available.
         # For these combinations ansible-test will use pip to install the 
requirements instead.
         case "${platform_version}/${python_version}" in
-            "14.4/3.14")
+            "14.5/3.14")
                 jinja2_pkg=""  # not available
                 cryptography_pkg=""  # not available
                 pyyaml_pkg=""  # not available
                 ;;
-            "15.0/3.12")
+            "15.1/3.14")
                 jinja2_pkg=""  # not available
                 cryptography_pkg=""  # not available
                 pyyaml_pkg=""  # not available
@@ -426,10 +426,15 @@
 {
     optimize_dnf
 
-    py_pkg_prefix="python3"
+    if [ "${python_version}" = "3.12" ]; then
+        py_pkg_prefix="python3"
+    else
+        py_pkg_prefix="python${python_version}"
+    fi
 
     packages="
         gcc
+        python3-packaging
         ${py_pkg_prefix}-devel
         ${py_pkg_prefix}-pip
         "
@@ -438,11 +443,19 @@
         packages="
             ${packages}
             ${py_pkg_prefix}-cryptography
-            ${py_pkg_prefix}-jinja2
-            ${py_pkg_prefix}-packaging
             ${py_pkg_prefix}-pyyaml
-            ${py_pkg_prefix}-resolvelib
             "
+
+        if [ "${python_version}" = "3.12" ]; then
+            # These packages are not available for all Python versions.
+            # Install them when possible, otherwise let ansible-test install 
them from PyPI.
+            packages="
+                ${packages}
+                ${py_pkg_prefix}-jinja2
+                ${py_pkg_prefix}-packaging
+                ${py_pkg_prefix}-resolvelib
+                "
+        fi
     fi
 
     retry_init
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/units/cli/galaxy/test_collection_extract_tar.py 
new/ansible_core-2.21.6/test/units/cli/galaxy/test_collection_extract_tar.py
--- 
old/ansible_core-2.21.5/test/units/cli/galaxy/test_collection_extract_tar.py    
    2026-10-05 17:06:50.000000000 +0200
+++ 
new/ansible_core-2.21.6/test/units/cli/galaxy/test_collection_extract_tar.py    
    1970-01-01 01:00:00.000000000 +0100
@@ -1,39 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2020 Ansible Project
-# GNU General Public License v3.0+ (see COPYING or 
https://www.gnu.org/licenses/gpl-3.0.txt)
-
-from __future__ import annotations
-
-import pytest
-
-from ansible.galaxy.collection import _extract_tar_dir
-
-
[email protected]
-def fake_tar_obj(mocker):
-    m_tarfile = mocker.Mock()
-    m_tarfile.type = mocker.Mock(return_value=b'99')
-    m_tarfile.SYMTYPE = mocker.Mock(return_value=b'22')
-
-    return m_tarfile
-
-
-def test_extract_tar_dir_exists(mocker, fake_tar_obj):
-    mocker.patch('os.makedirs', return_value=None)
-    m_makedir = mocker.patch('os.mkdir', return_value=None)
-    mocker.patch('os.path.isdir', return_value=True)
-
-    _extract_tar_dir(fake_tar_obj, '/some/dir', b'/some/dest')
-
-    assert not m_makedir.called
-
-
-def test_extract_tar_dir_does_not_exist(mocker, fake_tar_obj):
-    mocker.patch('os.makedirs', return_value=None)
-    m_makedir = mocker.patch('os.mkdir', return_value=None)
-    mocker.patch('os.path.isdir', return_value=False)
-
-    _extract_tar_dir(fake_tar_obj, '/some/dir', b'/some/dest')
-
-    assert m_makedir.called
-    assert m_makedir.call_args[0] == (b'/some/dir', 0o0755)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/ansible_core-2.21.5/test/units/module_utils/urls/test_mask_url.py 
new/ansible_core-2.21.6/test/units/module_utils/urls/test_mask_url.py
--- old/ansible_core-2.21.5/test/units/module_utils/urls/test_mask_url.py       
2026-10-05 17:06:50.000000000 +0200
+++ new/ansible_core-2.21.6/test/units/module_utils/urls/test_mask_url.py       
2026-11-02 18:32:18.000000000 +0100
@@ -36,3 +36,45 @@
 
     for notmasked in wanted:
         assert notmasked in masked
+
+
+_ipv6 = 'Invalid IPv6 URL'
+
+
[email protected](
+    'url, expected',
+    (
+        
('http://secretuser:secretpassword@badunicodeat.com:80/file.html?nothing=something',
 None),
+        ('http://secretuser:@badunicodeslash.com:443/file.html', None),
+        ('http://[email protected]:80', None),
+        
('http://:secretpassword@badunicodequestion.com:00/file.html?this=breaksparse', 
None),
+        ('https://[::1/index.html', _ipv6),
+        ('https://example.com]:443/index.html', _ipv6),
+        ('https://[fe80::1:8080/index.html', _ipv6),
+        ('//[::1/index.html', _ipv6),
+        ('https://secretuser:secretpass@[::1/index.html', _ipv6),
+        ('https://secretuser@[::1/index.html', _ipv6),
+        ('ftp://secretuser:secretpass@[fe80::1:8080/pub/file.txt', _ipv6),
+        ('https://:secretpass@::1]/index.html', _ipv6),
+        ('https://secretuser:secretpass@[::1', _ipv6),
+        # a scheme-relative url has no scheme to anchor on
+        ('//secretuser:secretpass@[::1/index.html', _ipv6),
+        # only `/` ends the authority, so a `?` or `#` cannot hide the userinfo
+        ('https://[::1secretuser:sec?retpass@host/index.html', _ipv6),
+        ('https://secretuser:secretpass@[::1#fragment', _ipv6),
+        # the last `@` separates userinfo from host, matching how urlparse 
splits it
+        ('https://secretuser:secret@pass@[::1/index.html', _ipv6),
+        # combine!
+        
('http://:secretpassword@[::1/:01/badunicodeat/file.html?this=breaksparse', 
None),
+    )
+)
+def test_mask_url_exceptions(url, expected):
+    with pytest.raises(ValueError, match="^(?!.*secret).*$") as e:
+        mask_url(url)
+
+    msg = str(e)
+    if expected is None:
+        assert "ValueError('mask_url could not parse the url provided')" in msg
+    else:
+        assert 'mask_url could not parse the url provided' in msg
+        assert expected in msg

++++++ ansible_core-2.21.5.tar.gz.sha256 -> ansible_core-2.21.6.tar.gz.sha256 
++++++
--- /work/SRC/openSUSE:Factory/ansible-core/ansible_core-2.21.5.tar.gz.sha256   
2026-10-08 13:28:41.120050075 +0200
+++ 
/work/SRC/openSUSE:Factory/.ansible-core.new.3759290/ansible_core-2.21.6.tar.gz.sha256
      2026-11-06 20:00:45.705176446 +0100
@@ -1 +1 @@
-65bb3148c58ebb843cd5074004c805f404f152114d98476dd823324616c9c6cb  
ansible_core-2.21.5.tar.gz
+3fa05b4b1af69f2fa19b49dde8edd5a23e693c2c1c3bda2d85fce73bd6cfc36d  
ansible_core-2.21.6.tar.gz

Reply via email to