This commit is contained in:
Abhijeet Kasurde 2026-02-03 19:16:27 -06:00 committed by GitHub
commit 964f2da0f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 9 deletions

View file

@ -0,0 +1,3 @@
---
bugfixes:
- apt_repository - validate the line in sources.list (https://github.com/ansible/ansible/issues/85715).

View file

@ -282,6 +282,59 @@ class SourcesList(object):
return '%s.list' % _cleanup_filename(' '.join(parts[:1]))
@staticmethod
def _validate_source(source: str) -> bool:
"""
Validate a source string according to the SOURCES.LIST(5).
See: https://manpages.debian.org/trixie/apt/sources.list.5.en.html#ONE-LINE-STYLE_FORMAT
"""
parts = source.split()
if not parts:
return False
# Extract the type and handle options
entry_type = parts[0]
if entry_type not in VALID_SOURCE_TYPES:
return False
# Check for options enclosed in square brackets
# The first element after the type might be the start of options
if len(parts) > 1 and parts[1].startswith('['):
if parts[1].endswith(']'):
# For single-word options
remaining_parts = parts[2:]
else:
# For multi-word options
end_bracket_index = -1
for i, part in enumerate(parts[2:], start=2):
if part.endswith(']'):
end_bracket_index = i
break
if end_bracket_index != -1:
remaining_parts = parts[end_bracket_index + 1:]
else:
# Malformed options, treat the whole thing as a single part for now.
remaining_parts = parts[1:]
return False
else:
remaining_parts = parts[1:]
# According to `sources.list(5)` man pages, only four fields are mandatory:
# * `Types` either `deb` or/and `deb-src`
# * `URIs` to repositories holding valid APT structure (unclear if multiple are allowed)
# * `Suites` usually being distribution codenames
# * `Component` most of the time `main`, but it's a section of the repository
if remaining_parts[1].endswith('/') and len(remaining_parts) > 2:
# Suites with trailing slash makes component optional
return False
if not remaining_parts[1].endswith('/') and len(remaining_parts) < 3:
# Invalid line format
return False
return True
def _parse(self, line, raise_if_invalid_or_disabled=False):
valid = False
enabled = True
@ -303,10 +356,7 @@ class SourcesList(object):
# Duplicated whitespaces in a valid source spec will be removed.
source = line.strip()
if source:
chunks = source.split()
if chunks[0] in VALID_SOURCE_TYPES:
valid = True
source = ' '.join(chunks)
valid = self._validate_source(source)
if raise_if_invalid_or_disabled and (not valid or not enabled):
raise InvalidSource(line)
@ -315,11 +365,11 @@ class SourcesList(object):
def load(self, file):
group = []
f = open(file, 'r')
for n, line in enumerate(f):
valid, enabled, source, comment = self._parse(line)
group.append((n, valid, enabled, source, comment))
self.files[file] = group
with open(file, 'r') as f:
for n, line in enumerate(f):
valid, enabled, source, comment = self._parse(line)
group.append((n, valid, enabled, source, comment))
self.files[file] = group
def save(self):
for filename, sources in list(self.files.items()):

View file

@ -354,6 +354,20 @@
- name: uninstall local-apt-repository with apt
apt: pkg=local-apt-repository state=absent purge=yes
# Invalid repo with no component
- name: Add repo with empty component
ansible.builtin.apt_repository:
repo: "deb http://archive.canonical.com/ubuntu jammy"
state: present
register: emptycomp_result
ignore_errors: true
- name: Assert that the repo was not added
assert:
that:
- emptycomp_result is failed
- "'Invalid repository string' in emptycomp_result.msg"
#
# TEST: PPA HTTPS URL
#

View file

@ -0,0 +1,31 @@
# Copyright: Contributors to the 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.modules.apt_repository import SourcesList
@pytest.mark.parametrize(
"line, expected", [
pytest.param("deb http://deb.debian.org/debian stable main contrib non-free", True, id="valid_line"),
pytest.param("# This is a commented line that should be ignored", False, id="commented_line"),
pytest.param("deb http://ftp.us.debian.org/debian sid main", True, id="no_options_line"),
pytest.param("deb-src http://ftp.debian.org/debian/ experimental/", True, id="suite_with_slash"),
pytest.param("deb-src http://ftp.debian.org/debian/ experimental/ main", False, id="suite_with_slash_and_component"),
pytest.param("deb [arch=amd64,i386] http://ftp.us.debian.org/debian sid main", True, id="multi_arch_option_line"),
pytest.param("deb [trusted=yes arch=amd64] https://example.com/debian focal", False, id="invalid_line"),
pytest.param("deb [trusted=yes arch=amd64] https://example.com/debian focal main", True, id="trusted_option_line"),
pytest.param("deb [trusted=yes signed-by=/etc/apt/key.gpg] http://my.repo.com/ubuntu focal-updates main", True, id="signed_by_option_line"),
pytest.param("deb-src [arch=amd64 trusted=yes] http://my.repo.com/ubuntu focal main universe", True, id="multiple_components_line"),
pytest.param("deb [arch=amd64,i386 trusted=yes] http://my.repo.com/ubuntu focal main", True, id="multiple_arch_trusted_option_line"),
pytest.param(
"deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu/ xenial-updates main restricted # a comment at the end",
True,
id="comment_at_end_line"
),
]
)
def test_validate(line, expected):
assert SourcesList._validate_source(line) == expected