diff --git a/id_configurator/modules/java.py b/id_configurator/modules/java.py index 4299eb8..200bc8c 100644 --- a/id_configurator/modules/java.py +++ b/id_configurator/modules/java.py @@ -62,7 +62,7 @@ from cryptography.hazmat.primitives.serialization import ( from id_configurator.config import JavaRepository, NamedContent from id_configurator.context import Context -from id_configurator.modules.base import Module, register +from id_configurator.modules.base import Module, register, sanitize_repository_name logger = logging.getLogger(__name__) @@ -170,17 +170,18 @@ def _build_maven_settings(repositories: list[JavaRepository]) -> ET.Element: repositories_el = ET.SubElement(profile, "repositories") plugin_repositories_el = ET.SubElement(profile, "pluginRepositories") for repository in repositories: + name = sanitize_repository_name(repository.name) for parent, tag in ( (repositories_el, "repository"), (plugin_repositories_el, "pluginRepository"), ): entry = ET.SubElement(parent, tag) - ET.SubElement(entry, "id").text = repository.name + ET.SubElement(entry, "id").text = name ET.SubElement(entry, "url").text = repository.url active_profiles = ET.SubElement(settings, "activeProfiles") ET.SubElement(active_profiles, "activeProfile").text = "id-configurator" mirror = ET.SubElement(ET.SubElement(settings, "mirrors"), "mirror") - ET.SubElement(mirror, "id").text = repositories[0].name + ET.SubElement(mirror, "id").text = sanitize_repository_name(repositories[0].name) ET.SubElement(mirror, "url").text = repositories[0].url ET.SubElement(mirror, "mirrorOf").text = "*" return settings diff --git a/id_configurator/modules/python.py b/id_configurator/modules/python.py index 7982738..4d28d87 100644 --- a/id_configurator/modules/python.py +++ b/id_configurator/modules/python.py @@ -41,7 +41,19 @@ _DISTUTILS_GLOBS = ("usr/lib/python3.*", "usr/local/lib/python3.*") def _toml_string(value: str) -> str: - return json.dumps(value) + """Render `value` as a TOML basic string. + + JSON and TOML basic strings share the same escape syntax, so + `json.dumps` is a convenient way to produce one - *except* for + non-ASCII characters: with the default `ensure_ascii=True`, a + character outside the Basic Multilingual Plane (e.g. an emoji) comes + out as a UTF-16 surrogate pair (`\\uXXXX\\uXXXX`), which is valid JSON + but not valid TOML - TOML's `\\uXXXX` must itself be a Unicode scalar + value, not one half of a surrogate pair. `ensure_ascii=False` instead + leaves every non-ASCII character as a literal, raw UTF-8 character, + which TOML basic strings allow directly. + """ + return json.dumps(value, ensure_ascii=False) def _trusted_host(repository: PythonRepository) -> str | None: @@ -68,8 +80,9 @@ def _write_pip_conf(context: Context, repositories: list[PythonRepository]) -> N def _write_uv_toml(context: Context, repositories: list[PythonRepository]) -> None: lines = ["native-tls = true", ""] for position, repository in enumerate(repositories): + name = sanitize_repository_name(repository.name) lines.append("[[index]]") - lines.append(f"name = {_toml_string(repository.name)}") + lines.append(f"name = {_toml_string(name)}") lines.append(f"url = {_toml_string(repository.index_url)}") if position == 0: lines.append("default = true") diff --git a/pyproject.toml b/pyproject.toml index 47049f6..5b92305 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ maintainers = [ name = "id-configurator" readme = "README.md" requires-python = ">=3.13" -version = "2026.10.6" +version = "2026.10.9" [project.scripts] id-configurator = "id_configurator:main" diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 2113056..68d870c 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -10,7 +10,7 @@ configurations: sources: [] apk: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'APK_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('APK_REPO_PORT' if scheme == 'https' else 'APK_REPO_HTTP_PORT') | env }}/ url_to_replace: https://dl-cdn.alpinelinux.org/* diff --git a/tests/configs/Go.yaml.j2 b/tests/configs/Go.yaml.j2 index 75089c6..f4dd8f6 100644 --- a/tests/configs/Go.yaml.j2 +++ b/tests/configs/Go.yaml.j2 @@ -25,7 +25,7 @@ configurations: repositories: [] go: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'GO_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('GO_REPO_PORT' if scheme == 'https' else 'GO_REPO_HTTP_PORT') | env }}/ ruby: diff --git a/tests/configs/Npm.yaml.j2 b/tests/configs/Npm.yaml.j2 index a1f0954..a281ef6 100644 --- a/tests/configs/Npm.yaml.j2 +++ b/tests/configs/Npm.yaml.j2 @@ -21,7 +21,7 @@ configurations: repositories: [] npm: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'NPM_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('NPM_REPO_PORT' if scheme == 'https' else 'NPM_REPO_HTTP_PORT') | env }}/ rust: diff --git a/tests/configs/NuGet.yaml.j2 b/tests/configs/NuGet.yaml.j2 index 2842a99..b605af5 100644 --- a/tests/configs/NuGet.yaml.j2 +++ b/tests/configs/NuGet.yaml.j2 @@ -29,6 +29,6 @@ configurations: repositories: [] nuget: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'NUGET_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('NUGET_REPO_PORT' if scheme == 'https' else 'NUGET_REPO_HTTP_PORT') | env }}/index.json diff --git a/tests/configs/Php.yaml.j2 b/tests/configs/Php.yaml.j2 index 1b11361..130506c 100644 --- a/tests/configs/Php.yaml.j2 +++ b/tests/configs/Php.yaml.j2 @@ -19,7 +19,7 @@ configurations: repositories: [] php: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'PHP_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('PHP_REPO_PORT' if scheme == 'https' else 'PHP_REPO_HTTP_PORT') | env }}/ npm: diff --git a/tests/configs/Python.yaml.j2 b/tests/configs/Python.yaml.j2 index 45bd9ed..c447d5e 100644 --- a/tests/configs/Python.yaml.j2 +++ b/tests/configs/Python.yaml.j2 @@ -15,7 +15,7 @@ configurations: keys: [] python: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'PYPI_REPO_SCHEME' | env %} index-url: {{ scheme }}://host.docker.internal:{{ ('PYPI_REPO_PORT' if scheme == 'https' else 'PYPI_REPO_HTTP_PORT') | env }}/simple/ java: diff --git a/tests/configs/Ruby.yaml.j2 b/tests/configs/Ruby.yaml.j2 index 12be460..f2d5286 100644 --- a/tests/configs/Ruby.yaml.j2 +++ b/tests/configs/Ruby.yaml.j2 @@ -27,7 +27,7 @@ configurations: repositories: [] ruby: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'RUBY_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('RUBY_REPO_PORT' if scheme == 'https' else 'RUBY_REPO_HTTP_PORT') | env }}/ nuget: diff --git a/tests/configs/Rust.yaml.j2 b/tests/configs/Rust.yaml.j2 index d1aa9a2..6713d3d 100644 --- a/tests/configs/Rust.yaml.j2 +++ b/tests/configs/Rust.yaml.j2 @@ -23,7 +23,7 @@ configurations: repositories: [] rust: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'CARGO_REPO_SCHEME' | env %} url: {{ scheme }}://host.docker.internal:{{ ('CARGO_REPO_PORT' if scheme == 'https' else 'CARGO_REPO_HTTP_PORT') | env }}/ is_sparse: true diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index a453237..61db80d 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -12,7 +12,7 @@ configurations: repositories: [] yum: repositories: - - name: demo + - name: 'demo _.-/\$.:@;''"' {% set scheme = 'YUM_REPO_SCHEME' | env %} index-url: {{ scheme }}://host.docker.internal:{{ ('YUM_REPO_PORT' if scheme == 'https' else 'YUM_REPO_HTTP_PORT') | env }}/ index-url-to-replace: https://mirrors.rockylinux.org/* diff --git a/tests/test_python.py b/tests/test_python.py index c993c83..178c63c 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -1,3 +1,5 @@ +import tomllib + from id_configurator.config import Configuration from id_configurator.context import Context from id_configurator.modules.python import PythonModule @@ -76,6 +78,33 @@ def test_apply_single_repository(tmp_path) -> None: assert not (dst / "usr/lib").exists() +def test_apply_sanitizes_uv_index_name_with_non_bmp_characters(tmp_path) -> None: + data = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "python": { + "repositories": [ + {"name": "demo 😀", "index-url": "https://example.invalid/simple"} + ] + } + }, + } + config = Configuration.model_validate(data) + dst = tmp_path / "dst" + context = Context(config=config, dst=dst, src=None) + + PythonModule().apply(context) + + uv_toml = (dst / "etc/uv/uv.toml").read_text(encoding="utf-8") + # The emoji isn't a plain identifier character, so sanitize_repository_name + # replaces it (and the space before it) with "_", same as every other + # non-ASCII or syntax-breaking character. + assert '"demo__"' in uv_toml + parsed = tomllib.loads(uv_toml) + assert parsed["index"][0]["name"] == "demo__" + + def test_apply_adds_trusted_host_for_non_https_repository(tmp_path) -> None: data = { "uuid": "12345678-1234-5678-1234-567812345678", diff --git a/uv.lock b/uv.lock index 2dbdc9b..2f03325 100644 --- a/uv.lock +++ b/uv.lock @@ -424,7 +424,7 @@ dependencies = [ ] name = "id-configurator" source = {editable = "."} -version = "2026.10.6" +version = "2026.10.9" [package.dev-dependencies] dev = [