feat: can specify several users to configure

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-10-05 20:46:38 +02:00
parent c498d42da0
commit bf56cdfcd2
20 changed files with 875 additions and 63 deletions
+66
View File
@@ -15,6 +15,12 @@ CONFIG_WITH_CERT = {
**MINIMAL_CONFIG,
"configurations": {"certificates": [{"name": "my-ca", "content": "fake"}]},
}
CONFIG_WITH_GO_REPO = {
**MINIMAL_CONFIG,
"configurations": {
"go": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
},
}
def write_config(tmp_path, data) -> str:
@@ -129,6 +135,66 @@ def test_apply_runs_relevant_module(tmp_path) -> None:
assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n"
def test_apply_with_user_option_writes_per_user_config(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--user",
"alice",
"--user",
"root",
],
)
assert result.exit_code == 0
assert (dst / "home/alice/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
assert (dst / "root/.config/go/env").read_text(encoding="utf-8") == (
"GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
)
def test_apply_with_auto_users_reads_passwd_from_src(tmp_path) -> None:
config_path = write_config(tmp_path, CONFIG_WITH_GO_REPO)
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
dst = tmp_path / "dst"
result = runner.invoke(
cli.app,
[
"apply",
"--config",
config_path,
"--dst",
str(dst),
"--src",
str(src),
"--auto-users",
],
)
assert result.exit_code == 0
# root is always included despite its uid 0; daemon (nologin shell) is
# a system account, excluded.
assert (dst / "root/.config/go/env").exists()
assert (dst / "home/alice/.config/go/env").exists()
assert not (dst / "usr/sbin/.config/go/env").exists()
def test_main_default_name_calls_app(monkeypatch) -> None:
mock_app = MagicMock()
monkeypatch.setattr(cli, "app", mock_app)
+132 -2
View File
@@ -8,14 +8,16 @@ CONFIG = Configuration.model_validate(
)
def make_context(tmp_path, with_src: bool = True) -> Context:
def make_context(
tmp_path, with_src: bool = True, users=None, auto_users: bool = False
) -> Context:
dst = tmp_path / "dst"
dst.mkdir()
src = None
if with_src:
src = tmp_path / "src"
src.mkdir()
return Context(config=CONFIG, dst=dst, src=src)
return Context(config=CONFIG, dst=dst, src=src, users=users, auto_users=auto_users)
def test_dst_path_within_destination(tmp_path) -> None:
@@ -97,3 +99,131 @@ def test_glob_src_matches_pattern(tmp_path) -> None:
(context.src / "usr/lib/python-legacy").mkdir(parents=True)
result = context.glob_src("usr/lib/python3.*")
assert [entry.name for entry in result] == ["python3.11", "python3.12"]
def test_users_defaults_to_empty_list(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.users == []
def test_users_keeps_explicit_names_in_order_without_duplicates(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, users=["alice", "bob", "alice"])
assert context.users == ["alice", "bob"]
def test_auto_users_expands_from_passwd_excluding_disabled_shells(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
# root is always included despite its uid 0; daemon (nologin shell) is
# excluded as a system account.
assert context.users == ["root", "alice"]
def test_auto_users_excludes_root_with_disabled_shell(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/usr/sbin/nologin\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_excludes_uid_below_default_uid_min(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["alice"]
def test_auto_users_honors_uid_min_from_login_defs(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"svc:x:999:999:service account:/home/svc:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
(src / "etc/login.defs").write_text("UID_MIN\t500\n", encoding="utf-8")
context = Context(config=CONFIG, dst=dst, src=src, auto_users=True)
assert context.users == ["svc", "alice"]
def test_auto_users_combines_with_explicit_names_without_duplicates(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
encoding="utf-8",
)
context = Context(
config=CONFIG, dst=dst, src=src, users=["bob", "alice"], auto_users=True
)
# explicit names come first, in order; auto-detected names are appended,
# skipping "alice" since it's already present.
assert context.users == ["bob", "alice", "root"]
def test_auto_users_false_ignores_passwd(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
(src / "etc").mkdir(parents=True)
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.users == []
def test_auto_users_without_src_is_empty(tmp_path) -> None:
context = make_context(tmp_path, with_src=False, auto_users=True)
assert context.users == []
def test_home_dir_for_root(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("root") == "root"
def test_home_dir_falls_back_to_convention(tmp_path) -> None:
context = make_context(tmp_path, with_src=False)
assert context.home_dir("alice") == "home/alice"
def test_home_dir_uses_passwd_entry_when_available(tmp_path) -> None:
dst = tmp_path / "dst"
dst.mkdir()
src = tmp_path / "src"
src.mkdir()
(src / "etc").mkdir()
(src / "etc/passwd").write_text(
"alice:x:1000:1000:Alice:/srv/alice:/bin/bash\n", encoding="utf-8"
)
context = Context(config=CONFIG, dst=dst, src=src)
assert context.home_dir("alice") == "srv/alice"
+12
View File
@@ -55,6 +55,18 @@ def test_apply_writes_goproxy(tmp_path) -> None:
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_writes_go_env_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
GoModule().apply(context)
go_env = (dst / "home/alice/.config/go/env").read_text(encoding="utf-8")
assert go_env == "GOPROXY=https://example.invalid/\nGOSUMDB=off\n"
def test_apply_joins_multiple_repositories_with_comma(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+42
View File
@@ -63,6 +63,48 @@ def test_apply_writes_nuget_config(tmp_path) -> None:
)
def test_apply_marks_non_https_source_as_insecure(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"nuget": {
"repositories": [{"name": "demo", "url": "http://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
NuGetModule().apply(context)
content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
assert content == (
'<?xml version="1.0" encoding="utf-8"?>\n'
"<configuration>\n"
" <packageSources>\n"
" <clear />\n"
' <add key="demo" value="http://example.invalid/" allowInsecureConnections="true" />\n'
" </packageSources>\n"
"</configuration>\n"
)
def test_apply_writes_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice", "root"]
)
NuGetModule().apply(context)
root_content = (dst / "root/.nuget/NuGet/NuGet.Config").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.nuget/NuGet/NuGet.Config").read_text(
encoding="utf-8"
)
assert alice_content == root_content
def test_apply_writes_multiple_sources_in_order(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+66 -2
View File
@@ -80,7 +80,8 @@ def test_apply_writes_global_composer_config(tmp_path) -> None:
"name": "demo",
"type": "composer",
"url": "https://example.invalid/composer",
}
},
{"packagist.org": False},
]
}
@@ -93,7 +94,70 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None:
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"]
names = [repo["name"] for repo in content["repositories"] if "name" in repo]
assert names == ["demo", "other"]
assert {"packagist.org": False} in content["repositories"]
def test_apply_disables_secure_http_and_packagist_when_repo_not_https(
tmp_path,
) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"php": {
"repositories": [
{"name": "demo", "url": "https://example.invalid/composer"},
{"name": "insecure", "url": "http://other.invalid/composer"},
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert content["config"] == {"secure-http": False}
assert {"packagist.org": False} in content["repositories"]
def test_apply_does_not_disable_secure_http_when_all_repos_https(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert "config" not in content
def test_apply_always_disables_packagist(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
PhpModule().apply(context)
config_path = dst / "root/.composer/config.json"
content = json.loads(config_path.read_text(encoding="utf-8"))
assert {"packagist.org": False} in content["repositories"]
def test_apply_writes_composer_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
PhpModule().apply(context)
root_content = (dst / "root/.composer/config.json").read_bytes()
alice_content = (dst / "home/alice/.composer/config.json").read_bytes()
assert alice_content == root_content
def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None:
+16
View File
@@ -105,6 +105,22 @@ def test_apply_multiple_repositories(tmp_path) -> None:
)
def test_apply_writes_poetry_config_for_each_configured_user(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
dst = tmp_path / "dst"
context = Context(config=config, dst=dst, src=None, users=["alice"])
PythonModule().apply(context)
root_content = (dst / "root/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
alice_content = (dst / "home/alice/.config/pypoetry/config.toml").read_text(
encoding="utf-8"
)
assert alice_content == root_content
def test_apply_writes_distutils_cfg_for_detected_python_prefixes(tmp_path) -> None:
config = Configuration.model_validate(ONE_REPO_CONFIG)
src = tmp_path / "src"
+104 -3
View File
@@ -6,7 +6,15 @@ CONFIG_WITH_REPO = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {"repositories": [{"name": "demo", "url": "https://example.invalid/"}]}
"rust": {
"repositories": [
{
"name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
},
}
@@ -16,8 +24,16 @@ CONFIG_WITH_TWO_REPOS = {
"configurations": {
"rust": {
"repositories": [
{"name": "demo", "url": "https://example.invalid/"},
{"name": "other", "url": "https://other.invalid/"},
{
"name": "demo",
"url": "https://example.invalid/",
"is_sparse": True,
},
{
"name": "other",
"url": "https://other.invalid/",
"is_sparse": True,
},
]
}
},
@@ -61,6 +77,91 @@ def test_apply_writes_replacement_source(tmp_path) -> None:
)
def test_apply_sanitizes_repository_names_with_special_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [
{
"name": "weird.demo]\nname",
"url": "https://example.invalid/",
"is_sparse": True,
}
]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "weird_demo__name"\n'
"\n"
"[source.weird_demo__name]\n"
'registry = "sparse+https://example.invalid/"\n'
)
def test_apply_does_not_prefix_url_when_not_sparse(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"rust": {
"repositories": [{"name": "demo", "url": "https://example.invalid/"}]
}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "demo"\n'
"\n"
"[source.demo]\n"
'registry = "https://example.invalid/"\n'
)
def test_apply_prefixes_url_with_sparse_when_configured(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None)
RustModule().apply(context)
config_toml = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
assert config_toml == (
"[source.crates-io]\n"
'replace-with = "demo"\n'
"\n"
"[source.demo]\n"
'registry = "sparse+https://example.invalid/"\n'
)
def test_apply_writes_cargo_config_for_each_configured_user(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(
config=make_config(CONFIG_WITH_REPO), dst=dst, src=None, users=["alice"]
)
RustModule().apply(context)
root_content = (dst / ".cargo/config.toml").read_text(encoding="utf-8")
alice_content = (dst / "home/alice/.cargo/config.toml").read_text(encoding="utf-8")
assert alice_content == root_content
def test_apply_writes_additional_repositories_as_named_registries(tmp_path) -> None:
dst = tmp_path / "dst"
context = Context(config=make_config(CONFIG_WITH_TWO_REPOS), dst=dst, src=None)
+110
View File
@@ -0,0 +1,110 @@
from id_configurator.users import PasswdEntry, parse_login_defs_uid_min, parse_passwd
PASSWD_CONTENT = (
"root:x:0:0:root:/root:/bin/bash\n"
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
"daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n"
"sync:x:4:65534:sync:/bin:/bin/sync\n"
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/false\n"
"# a comment\n"
"\n"
)
def test_parse_passwd_returns_one_entry_per_line() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert [entry.name for entry in entries] == [
"root",
"alice",
"daemon",
"sync",
"nobody",
]
def test_parse_passwd_skips_comments_and_blank_lines() -> None:
entries = parse_passwd(PASSWD_CONTENT)
assert len(entries) == 5
def test_parse_passwd_extracts_uid_home_and_shell() -> None:
entries = {entry.name: entry for entry in parse_passwd(PASSWD_CONTENT)}
assert entries["alice"].uid == 1000
assert entries["alice"].home == "/home/alice"
assert entries["alice"].shell == "/bin/bash"
def test_parse_passwd_skips_malformed_lines() -> None:
entries = parse_passwd(
"tooshort:x:0\nnotanumber:x:abc:0:x:/home/x:/bin/bash\n"
"root:x:0:0:root:/root:/bin/bash\n"
)
assert [entry.name for entry in entries] == ["root"]
def test_has_login_shell_true_for_regular_shell() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash") # noqa: B604
assert entry.has_login_shell is True
def test_has_login_shell_false_for_nologin_sync_or_false() -> None:
nologin = PasswdEntry(
name="daemon",
uid=1,
home="/usr/sbin",
shell="/usr/sbin/nologin", # noqa: B604
)
sync = PasswdEntry(name="sync", uid=4, home="/bin", shell="/bin/sync") # noqa: B604
false_shell = PasswdEntry(
name="nobody",
uid=65534,
home="/nonexistent",
shell="/usr/sbin/false", # noqa: B604
)
assert nologin.has_login_shell is False
assert sync.has_login_shell is False
assert false_shell.has_login_shell is False
def test_is_regular_user_true_for_human_account() -> None:
entry = PasswdEntry(name="alice", uid=1000, home="/home/alice", shell="/bin/bash") # noqa: B604
assert entry.is_regular_user() is True
def test_is_regular_user_true_for_root_despite_uid_zero() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/bin/bash") # noqa: B604
assert entry.is_regular_user() is True
def test_is_regular_user_false_for_root_with_disabled_shell() -> None:
entry = PasswdEntry(name="root", uid=0, home="/root", shell="/usr/sbin/nologin") # noqa: B604
assert entry.is_regular_user() is False
def test_is_regular_user_false_for_disabled_shell_despite_high_uid() -> None:
entry = PasswdEntry(
name="nobody",
uid=65534,
home="/nonexistent",
shell="/usr/sbin/false", # noqa: B604
)
assert entry.is_regular_user() is False
def test_is_regular_user_honors_custom_uid_min() -> None:
entry = PasswdEntry(name="svc", uid=500, home="/home/svc", shell="/bin/bash") # noqa: B604
assert entry.is_regular_user(uid_min=1000) is False
assert entry.is_regular_user(uid_min=100) is True
def test_parse_login_defs_uid_min_found() -> None:
content = "# comment\nUID_MIN\t500\nGID_MIN\t500\n"
assert parse_login_defs_uid_min(content) == 500
def test_parse_login_defs_uid_min_missing() -> None:
assert parse_login_defs_uid_min("GID_MIN\t500\n") is None
def test_parse_login_defs_uid_min_malformed_value() -> None:
assert parse_login_defs_uid_min("UID_MIN\tnot-a-number\n") is None
+31 -10
View File
@@ -313,10 +313,26 @@ def test_apply_matches_index_url_to_replace_with_unrelated_files_and_multiple_re
assert (dst / "etc/yum.repos.d/second.repo").exists()
def test_apply_sanitizes_repository_and_key_names_with_slash(
tmp_path, monkeypatch
) -> None:
def test_apply_sanitizes_key_name_with_slash(tmp_path, monkeypatch) -> None:
mock_run = _mock_gpg_success(monkeypatch)
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
"configurations": {
"yum": {"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}]}
},
}
dst = tmp_path / "dst"
context = Context(config=make_config(data), dst=dst, src=None)
YumModule().apply(context)
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-.._.._evil-key").read_bytes()
assert key_content == b"fake-rpm-gpg-key-bytes"
mock_run.assert_called_once()
def test_apply_sanitizes_repository_name_with_disallowed_characters(tmp_path) -> None:
data = {
"uuid": "12345678-1234-5678-1234-567812345678",
"organisation": "InterDiode",
@@ -324,11 +340,10 @@ def test_apply_sanitizes_repository_and_key_names_with_slash(
"yum": {
"repositories": [
{
"name": "../../evil-repo",
"name": "weird/repo]here[bad",
"index-url": "https://example.invalid/yum",
}
],
"keys": [{"name": "../../evil-key", "content": KEY_CONTENT}],
]
}
},
}
@@ -337,10 +352,16 @@ def test_apply_sanitizes_repository_and_key_names_with_slash(
YumModule().apply(context)
assert (dst / "etc/yum.repos.d/.._.._evil-repo.repo").exists()
key_content = (dst / "etc/pki/rpm-gpg/RPM-GPG-KEY-.._.._evil-key").read_bytes()
assert key_content == b"fake-rpm-gpg-key-bytes"
mock_run.assert_called_once()
repo_file = dst / "etc/yum.repos.d/weird_repo_here_bad.repo"
assert repo_file.exists()
repo_content = repo_file.read_text(encoding="utf-8")
assert repo_content == (
"[weird_repo_here_bad]\n"
"name=weird_repo_here_bad\n"
"baseurl=https://example.invalid/yum\n"
"enabled=1\n"
"gpgcheck=0\n"
)
def test_import_key_raises_when_gpg_binary_missing(tmp_path, monkeypatch) -> None: