Files
mdaleo404 62d505a17e Slim CI to a single PR job and fix environment-dependent tests
- Consolidate workflow into one job (Python 3.11/3.13), drop pip-audit,
  package build, and best-effort integration tests
- Make helper-ownership tests root-safe and the timefmt zone test
  deterministic across host timezones
2026-09-25 11:43:55 +01:00

190 lines
6.0 KiB
Python

from __future__ import annotations
import os
import stat
import pytest
from schedls.errors import InvalidNameError, SafetyRefusalError
from schedls.runner import CommandRunner
from schedls.security import (
atomic_write_text,
check_replaceable,
check_trusted_directory,
is_managed_unit,
is_safe_unit_name,
remove_file,
resolve_helper,
unit_name,
validate_managed_unit_name,
validate_name,
)
_UNTRUSTED_UID = 65534
def _relinquish_ownership(path) -> None:
"""Own a fixture by a non-root user when the suite itself runs as root.
The helper checks distinguish root-owned files from user-owned ones, so a
root test process must make its "untrusted" fixtures owned by someone else.
"""
if os.geteuid() == 0:
os.chown(path, _UNTRUSTED_UID, _UNTRUSTED_UID)
@pytest.mark.parametrize(
"name",
["backup", "postgres-backup", "report.daily", "sync_home", "a", "A1._-", "x" * 64],
)
def test_valid_names(name: str) -> None:
assert validate_name(name) == name
@pytest.mark.parametrize(
"name",
["", "../foo", "foo/bar", "name with spaces", "$(command)", ".hidden", "-lead", "x" * 65, "foo\nbar", "foo\n"],
)
def test_invalid_names(name: str) -> None:
with pytest.raises(InvalidNameError):
validate_name(name)
def test_unit_name() -> None:
assert unit_name("backup", "timer") == "schedls-backup.timer"
assert is_managed_unit("schedls-backup.timer")
assert not is_managed_unit("certbot.timer")
with pytest.raises(InvalidNameError):
unit_name("../evil", "timer")
def test_atomic_write_and_remove(tmp_path) -> None:
target = tmp_path / "file.txt"
atomic_write_text(str(target), "hello\n", mode=0o600)
assert target.read_text() == "hello\n"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert remove_file(str(target)) is True
assert not target.exists()
assert remove_file(str(target)) is False
def test_atomic_write_refuses_symlink(tmp_path) -> None:
real = tmp_path / "real.txt"
real.write_text("secret")
link = tmp_path / "link.txt"
link.symlink_to(real)
with pytest.raises(SafetyRefusalError):
atomic_write_text(str(link), "overwrite")
assert real.read_text() == "secret"
def test_remove_refuses_symlink(tmp_path) -> None:
real = tmp_path / "real.txt"
real.write_text("secret")
link = tmp_path / "link.txt"
link.symlink_to(real)
with pytest.raises(SafetyRefusalError):
remove_file(str(link))
def test_check_replaceable_ownership(tmp_path) -> None:
target = tmp_path / "file.txt"
target.write_text("x")
check_replaceable(str(target), expected_uid=os.getuid())
with pytest.raises(SafetyRefusalError):
check_replaceable(str(target), expected_uid=os.getuid() + 99999)
def test_resolve_helper_rejects_missing() -> None:
assert resolve_helper("definitely-not-a-real-helper-xyz") is None
def test_resolve_helper_rejects_world_writable_dir(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
helper_dir.chmod(0o777)
monkeypatch.setenv("PATH", str(helper_dir))
assert resolve_helper("evilhelper") is None
def test_resolve_helper_rejects_group_writable_file(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o775)
monkeypatch.setenv("PATH", str(helper_dir))
assert resolve_helper("evilhelper") is None
def test_resolve_helper_rejects_user_owned_dir_when_root(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "evilhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
_relinquish_ownership(helper_dir)
monkeypatch.setenv("PATH", str(helper_dir))
monkeypatch.setattr(os, "geteuid", lambda: 0)
assert resolve_helper("evilhelper") is None
def test_resolve_helper_accepts_own_dir_when_not_root(tmp_path, monkeypatch) -> None:
helper_dir = tmp_path / "bin"
helper_dir.mkdir()
helper = helper_dir / "goodhelper"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
monkeypatch.setenv("PATH", str(helper_dir))
monkeypatch.setattr(os, "geteuid", lambda: 4242)
assert resolve_helper("goodhelper") == str(helper)
def test_runner_refuses_untrusted_absolute_helper(tmp_path, monkeypatch) -> None:
helper = tmp_path / "evil"
helper.write_text("#!/bin/sh\ntrue\n")
helper.chmod(0o755)
_relinquish_ownership(helper)
monkeypatch.setattr(os, "geteuid", lambda: 0)
with pytest.raises(SafetyRefusalError):
CommandRunner().run([str(helper)])
def test_runner_refuses_relative_executable() -> None:
with pytest.raises(SafetyRefusalError):
CommandRunner().run(["bin/evil"])
def test_check_trusted_directory_owner_and_mode(tmp_path) -> None:
check_trusted_directory(str(tmp_path), expected_uid=os.getuid())
with pytest.raises(SafetyRefusalError):
check_trusted_directory(str(tmp_path), expected_uid=os.getuid() + 99999)
tmp_path.chmod(0o777)
with pytest.raises(SafetyRefusalError):
check_trusted_directory(str(tmp_path), expected_uid=os.getuid())
def test_validate_managed_unit_name() -> None:
assert validate_managed_unit_name("schedls-backup.timer") == "schedls-backup.timer"
assert validate_managed_unit_name("schedls-a_b.service") == "schedls-a_b.service"
for unit in (
"../../etc/passwd",
"schedls-../x.timer",
"certbot.timer",
"schedls-x.timer/../y",
"schedls-x.timer\n",
):
with pytest.raises(SafetyRefusalError):
validate_managed_unit_name(unit)
def test_is_safe_unit_name() -> None:
assert is_safe_unit_name("certbot.service")
assert is_safe_unit_name("foo@bar.timer")
assert not is_safe_unit_name("--output=json")
assert not is_safe_unit_name("../../evil.service")
assert not is_safe_unit_name("unit with spaces.service")