Initial code commit
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
"""Shared test fixtures and a controllable fake command runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.errors import DependencyMissingError, OperationalError
|
||||
from schedls.runner import Completed
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
"""A duck-typed stand-in for :class:`schedls.runner.CommandRunner`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: dict[str, Callable] | None = None,
|
||||
available: Iterable[str] = (),
|
||||
) -> None:
|
||||
self.handlers = handlers or {}
|
||||
self.available = set(available)
|
||||
self.calls: list[tuple[list[str], str, str | None]] = []
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
return name in self.available
|
||||
|
||||
def resolve(self, name: str) -> str | None:
|
||||
return name if name in self.available else None
|
||||
|
||||
def run(self, argv, *, env_policy="minimal", timeout=None, check=True, input_text=None):
|
||||
args = list(argv)
|
||||
self.calls.append((args, env_policy, input_text))
|
||||
handler = self.handlers.get(args[0])
|
||||
if handler is None:
|
||||
if args[0] not in self.available:
|
||||
raise DependencyMissingError(f"required command not found: {args[0]}")
|
||||
completed = Completed(tuple(args), 0, "", "")
|
||||
else:
|
||||
completed = _coerce(args, handler(args, input_text))
|
||||
if check and completed.returncode != 0:
|
||||
raise OperationalError(
|
||||
f"command failed ({completed.returncode}): {' '.join(args)}",
|
||||
hint=completed.stderr or completed.stdout or None,
|
||||
)
|
||||
return completed
|
||||
|
||||
def try_run(self, argv, *, env_policy="minimal", timeout=None, input_text=None):
|
||||
if argv[0] not in self.available and argv[0] not in self.handlers:
|
||||
return None
|
||||
try:
|
||||
return self.run(argv, env_policy=env_policy, input_text=input_text)
|
||||
except OperationalError:
|
||||
return None
|
||||
|
||||
|
||||
def _coerce(args: list[str], result) -> Completed:
|
||||
if isinstance(result, Completed):
|
||||
return result
|
||||
if isinstance(result, str):
|
||||
return Completed(tuple(args), 0, result, "")
|
||||
if isinstance(result, tuple):
|
||||
if len(result) == 3:
|
||||
return Completed(tuple(args), *result)
|
||||
if len(result) == 2:
|
||||
return Completed(tuple(args), 0, result[0], result[1])
|
||||
raise TypeError(f"unsupported fake result: {result!r}")
|
||||
raise TypeError(f"unsupported fake result: {result!r}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_runner() -> FakeRunner:
|
||||
return FakeRunner()
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# schedls:begin name=backup
|
||||
0 2 * * * /usr/local/bin/backup /srv/data
|
||||
# schedls:end name=backup
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Managed by schedls
|
||||
# Name: backup
|
||||
|
||||
[Unit]
|
||||
Description=schedls job backup
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart="/usr/local/bin/backup" "/srv/data"
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
# Managed by schedls
|
||||
# Name: backup
|
||||
|
||||
[Unit]
|
||||
Description=schedls job backup (timer)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 02:00:00
|
||||
Persistent=true
|
||||
Unit=schedls-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.errors import InvalidScheduleError
|
||||
from schedls.operations.calendar import run_calendar
|
||||
from schedls.output import Output
|
||||
from schedls.runner import CommandRunner
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _has_analyze() -> bool:
|
||||
return CommandRunner().has("systemd-analyze")
|
||||
|
||||
|
||||
def test_calendar_valid() -> None:
|
||||
if not _has_analyze():
|
||||
pytest.skip("systemd-analyze not available")
|
||||
stream = io.StringIO()
|
||||
output = Output(color="never", stdout=stream)
|
||||
run_calendar(CommandRunner(), output, "Mon..Fri 02:30", next_count=3)
|
||||
text = stream.getvalue()
|
||||
assert "Normalized" in text
|
||||
assert "Mon..Fri *-*-* 02:30:00" in text
|
||||
|
||||
|
||||
def test_calendar_invalid() -> None:
|
||||
if not _has_analyze():
|
||||
pytest.skip("systemd-analyze not available")
|
||||
stream = io.StringIO()
|
||||
output = Output(color="never", stdout=stream)
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
run_calendar(CommandRunner(), output, "definitely not a calendar", next_count=1)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.backends.cron import CronBackend
|
||||
from schedls.models import Backend, Command, JobSpec, Scope
|
||||
from schedls.runner import CommandRunner
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
NAME = "schedls-it-cron"
|
||||
|
||||
|
||||
def _backend_or_skip() -> CronBackend:
|
||||
backend = CronBackend(CommandRunner())
|
||||
if not backend.available():
|
||||
pytest.skip("crontab not available")
|
||||
if os.environ.get("SCHEDLS_RUN_CRON_TESTS") != "1":
|
||||
pytest.skip("set SCHEDLS_RUN_CRON_TESTS=1 to allow crontab mutation")
|
||||
return backend
|
||||
|
||||
|
||||
def _restore(backend: CronBackend, original: str) -> None:
|
||||
backend.runner.run(["crontab", "-"], env_policy="identity", input_text=original, check=False)
|
||||
|
||||
|
||||
def test_cron_lifecycle_preserves_content() -> None:
|
||||
backend = _backend_or_skip()
|
||||
original_document = backend.read()
|
||||
original = original_document.text
|
||||
# Add an unrelated line first so we can prove preservation.
|
||||
seeded = original + ("\n" if original and not original.endswith("\n") else "")
|
||||
seeded += "# unrelated comment\n"
|
||||
backend.runner.run(["crontab", "-"], env_policy="identity", input_text=seeded)
|
||||
try:
|
||||
spec = JobSpec(
|
||||
name=NAME,
|
||||
backend=Backend.CRON,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/usr/bin/true", "a b", "50%")),
|
||||
cron_expression="15 2 * * *",
|
||||
)
|
||||
plan = backend.plan_create(spec)
|
||||
result = backend.apply(plan)
|
||||
assert result.changed
|
||||
|
||||
jobs = [job for job in backend.discover([Scope.USER]) if job.name == NAME]
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].managed is True
|
||||
assert jobs[0].schedule.expression == "15 2 * * *"
|
||||
|
||||
raw = backend.read().text
|
||||
assert "# unrelated comment" in raw
|
||||
assert "a\\%b" not in raw # sanity: percent is escaped, not raw in a bad way
|
||||
|
||||
job = backend.find(NAME)
|
||||
assert job is not None
|
||||
backend.apply(backend.plan_remove(job))
|
||||
assert "# unrelated comment" in backend.read().text
|
||||
assert backend.find(NAME) is None
|
||||
finally:
|
||||
_restore(backend, original)
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.backends.systemd import SystemdBackend
|
||||
from schedls.models import Backend, Command, JobSpec, Scope
|
||||
from schedls.runner import CommandRunner
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
NAME = "schedls-it"
|
||||
|
||||
|
||||
def _backend_or_skip() -> SystemdBackend:
|
||||
backend = SystemdBackend(CommandRunner())
|
||||
if not backend.has_systemctl():
|
||||
pytest.skip("systemctl not available")
|
||||
if not backend.manager_ok(Scope.USER):
|
||||
pytest.skip("user systemd manager not reachable")
|
||||
if not backend.has_analyze():
|
||||
pytest.skip("systemd-analyze not available")
|
||||
return backend
|
||||
|
||||
|
||||
def _spec(calendar: str) -> JobSpec:
|
||||
return JobSpec(
|
||||
name=NAME,
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/bin/true",)),
|
||||
calendar=(calendar,),
|
||||
persistent=True,
|
||||
)
|
||||
|
||||
|
||||
def test_systemd_timer_lifecycle() -> None:
|
||||
backend = _backend_or_skip()
|
||||
directory = backend._unit_dir(Scope.USER)
|
||||
timer_path = os.path.join(directory, f"schedls-{NAME}.timer")
|
||||
service_path = os.path.join(directory, f"schedls-{NAME}.service")
|
||||
# Clean any leftover from a previous run.
|
||||
if os.path.exists(timer_path):
|
||||
existing = backend.find(NAME)
|
||||
if existing is not None:
|
||||
backend.apply(backend.plan_remove(existing))
|
||||
try:
|
||||
plan = backend.plan_create(_spec("*-*-* 03:30:00"))
|
||||
result = backend.apply(plan)
|
||||
assert result.changed
|
||||
assert os.path.exists(timer_path)
|
||||
assert os.path.exists(service_path)
|
||||
|
||||
job = backend.find(NAME)
|
||||
assert job is not None
|
||||
assert job.managed is True
|
||||
assert job.schedule.expression == "*-*-* 03:30:00"
|
||||
assert job.command.argv == ("/bin/true",)
|
||||
|
||||
backend.apply(backend.plan_set_enabled(job, False))
|
||||
job = backend.find(NAME)
|
||||
assert job is not None and job.enabled is False
|
||||
|
||||
backend.apply(backend.plan_set_enabled(job, True))
|
||||
job = backend.find(NAME)
|
||||
assert job is not None and job.enabled is True
|
||||
|
||||
backend.apply(backend.plan_update(job, _spec("*-*-* 05:15:00")))
|
||||
job = backend.find(NAME)
|
||||
assert job is not None
|
||||
assert job.schedule.expression == "*-*-* 05:15:00"
|
||||
|
||||
backend.apply(backend.plan_remove(job))
|
||||
assert not os.path.exists(timer_path)
|
||||
assert not os.path.exists(service_path)
|
||||
assert backend.find(NAME) is None
|
||||
finally:
|
||||
leftover = backend.find(NAME)
|
||||
if leftover is not None:
|
||||
backend.apply(backend.plan_remove(leftover))
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parents[2] / "src" / "schedls"
|
||||
|
||||
|
||||
def test_no_shell_true_in_production_code() -> None:
|
||||
offenders = []
|
||||
for path in SRC.rglob("*.py"):
|
||||
text = path.read_text()
|
||||
if "subprocess" in text and "shell=True" in text:
|
||||
offenders.append(str(path))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_subprocess_confined_to_runner() -> None:
|
||||
offenders = []
|
||||
for path in SRC.rglob("*.py"):
|
||||
if "import subprocess" in path.read_text() and path.name != "runner.py":
|
||||
offenders.append(str(path))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_runner_explicitly_disables_shell() -> None:
|
||||
assert "shell=False" in (SRC / "runner.py").read_text()
|
||||
|
||||
|
||||
def test_no_os_system_or_popen() -> None:
|
||||
for path in SRC.rglob("*.py"):
|
||||
text = path.read_text()
|
||||
assert "os.system(" not in text, path
|
||||
assert "os.popen(" not in text, path
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.cli import _parse_environment, _spec_for_new, build_parser, main, split_command
|
||||
from schedls.errors import InvalidNameError, UsageError
|
||||
from schedls.models import Backend, Scope
|
||||
|
||||
|
||||
def test_split_command() -> None:
|
||||
head, tail = split_command(["new", "x", "--timer", "--", "/bin/true", "arg"])
|
||||
assert head == ["new", "x", "--timer"]
|
||||
assert tail == ["/bin/true", "arg"]
|
||||
head, tail = split_command(["show", "x"])
|
||||
assert head == ["show", "x"]
|
||||
assert tail == []
|
||||
|
||||
|
||||
def test_edit_command_dest_does_not_shadow_subcommand() -> None:
|
||||
args = build_parser().parse_args(["edit", "backup", "--command"])
|
||||
assert args.command == "edit"
|
||||
assert args.replace_command is True
|
||||
|
||||
|
||||
def test_spec_for_new_systemd() -> None:
|
||||
args = build_parser().parse_args(["new", "backup", "--timer", "--daily", "02:00", "--persistent"])
|
||||
spec = _spec_for_new(args, ["/usr/local/bin/backup", "/srv/data"])
|
||||
assert spec.backend is Backend.SYSTEMD
|
||||
assert spec.scope is Scope.USER
|
||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||
assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data")
|
||||
assert spec.persistent is True
|
||||
|
||||
|
||||
def test_spec_for_new_cron() -> None:
|
||||
args = build_parser().parse_args(["new", "cleanup", "--cron", "--cron-expr", "0 4 * * 0"])
|
||||
spec = _spec_for_new(args, ["/usr/local/bin/cleanup"])
|
||||
assert spec.backend is Backend.CRON
|
||||
assert spec.cron_expression == "0 4 * * 0"
|
||||
|
||||
|
||||
def test_spec_for_new_system_scope() -> None:
|
||||
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--system"])
|
||||
spec = _spec_for_new(args, ["/bin/true"])
|
||||
assert spec.scope is Scope.SYSTEM
|
||||
|
||||
|
||||
def test_spec_for_new_invalid_name() -> None:
|
||||
args = build_parser().parse_args(["new", "../evil", "--timer", "--daily", "02:00"])
|
||||
with pytest.raises(InvalidNameError):
|
||||
_spec_for_new(args, ["/bin/true"])
|
||||
|
||||
|
||||
def test_spec_for_new_requires_schedule() -> None:
|
||||
args = build_parser().parse_args(["new", "x", "--timer"])
|
||||
with pytest.raises(UsageError):
|
||||
_spec_for_new(args, ["/bin/true"])
|
||||
|
||||
|
||||
def test_new_without_backend_is_usage_error() -> None:
|
||||
assert main(["new", "x", "--daily", "02:00"]) == 2
|
||||
|
||||
|
||||
def test_new_without_name_is_usage_error() -> None:
|
||||
assert main(["new", "--timer", "--daily", "02:00"]) == 2
|
||||
|
||||
|
||||
def test_interactive_conflicts_with_json() -> None:
|
||||
assert main(["--json", "new", "x", "-i", "--timer", "--daily", "02:00"]) == 2
|
||||
|
||||
|
||||
def test_interactive_requires_terminal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", _FakeStdin(isatty=False))
|
||||
assert main(["new", "x", "-i", "--timer", "--daily", "02:00"]) == 3
|
||||
|
||||
|
||||
class _FakeStdin:
|
||||
def __init__(self, *, isatty: bool) -> None:
|
||||
self._isatty = isatty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty
|
||||
|
||||
|
||||
def test_spec_for_new_shell_mode() -> None:
|
||||
args = build_parser().parse_args(["new", "x", "--timer", "--daily", "02:00", "--shell", "echo hi | cat"])
|
||||
spec = _spec_for_new(args, [])
|
||||
assert spec.command.shell is True
|
||||
assert spec.command.raw == "echo hi | cat"
|
||||
|
||||
|
||||
def test_spec_for_new_rejects_systemd_flags_for_cron() -> None:
|
||||
for extra in (["--env", "A=1"], ["--working-directory", "/srv/data"], ["--persistent"], ["--jitter", "5min"]):
|
||||
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", *extra])
|
||||
with pytest.raises(UsageError):
|
||||
_spec_for_new(args, ["/bin/true"])
|
||||
|
||||
|
||||
def test_spec_for_new_rejects_cron_expr_for_timer() -> None:
|
||||
args = build_parser().parse_args(["new", "x", "--timer", "--cron-expr", "0 4 * * *"])
|
||||
with pytest.raises(UsageError):
|
||||
_spec_for_new(args, ["/bin/true"])
|
||||
|
||||
|
||||
def test_spec_for_new_rejects_system_scope_for_cron() -> None:
|
||||
args = build_parser().parse_args(["new", "x", "--cron", "--daily", "02:00", "--system"])
|
||||
with pytest.raises(UsageError):
|
||||
_spec_for_new(args, ["/bin/true"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "1000001", "abc"])
|
||||
def test_logs_lines_must_be_a_bounded_positive_integer(value: str) -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
build_parser().parse_args(["logs", "x", "--lines", value])
|
||||
|
||||
|
||||
def test_parse_environment() -> None:
|
||||
assert _parse_environment(["A=1", "B=x=y"]) == (("A", "1"), ("B", "x=y"))
|
||||
with pytest.raises(UsageError):
|
||||
_parse_environment(["NOVALUE"])
|
||||
with pytest.raises(UsageError):
|
||||
_parse_environment(["1BAD=1"])
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls import convenience
|
||||
from schedls.errors import InvalidScheduleError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("time", "expected"),
|
||||
[("02:00", "*-*-* 02:00:00"), ("2:05", "*-*-* 02:05:00"), ("23:59:59", "*-*-* 23:59:59")],
|
||||
)
|
||||
def test_daily_calendar(time: str, expected: str) -> None:
|
||||
assert convenience.daily_calendar(time) == expected
|
||||
|
||||
|
||||
def test_weekdays_and_weekly_calendar() -> None:
|
||||
assert convenience.weekdays_calendar("08:30") == "Mon..Fri *-*-* 08:30:00"
|
||||
assert convenience.weekly_calendar("sun", "04:00") == "Sun *-*-* 04:00:00"
|
||||
assert convenience.weekly_calendar("Saturday", "4:00") == "Sat *-*-* 04:00:00"
|
||||
assert convenience.monthly_calendar("1", "06:00") == "*-*-01 06:00:00"
|
||||
|
||||
|
||||
def test_cron_variants() -> None:
|
||||
assert convenience.daily_cron("02:00") == "0 2 * * *"
|
||||
assert convenience.weekdays_cron("08:30") == "30 8 * * 1-5"
|
||||
assert convenience.weekly_cron("sun", "04:00") == "0 4 * * 0"
|
||||
assert convenience.monthly_cron("15", "06:00") == "0 6 15 * *"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["25:00", "02:60", "nope", "2", "02:00:99"])
|
||||
def test_invalid_time(bad: str) -> None:
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
convenience.daily_calendar(bad)
|
||||
|
||||
|
||||
def test_invalid_weekday_and_day() -> None:
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
convenience.weekly_calendar("funday", "04:00")
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
convenience.monthly_calendar("32", "06:00")
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
convenience.monthly_calendar("x", "06:00")
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.backends.cron import CrontabDocument
|
||||
from schedls.errors import ConflictError, SafetyRefusalError
|
||||
|
||||
SAMPLE = (
|
||||
"MAILTO=admin@example.net\n"
|
||||
"SHELL=/bin/bash\n"
|
||||
"PATH=/usr/local/bin:/usr/bin\n"
|
||||
"\n"
|
||||
"# personal job\n"
|
||||
"15 7 * * * ~/bin/foo\n"
|
||||
"@daily /usr/local/bin/nightly\n"
|
||||
"\n"
|
||||
"# schedls:begin name=backup\n"
|
||||
"0 2 * * * /usr/local/bin/backup /srv/data\n"
|
||||
"# schedls:end name=backup\n"
|
||||
)
|
||||
|
||||
|
||||
def test_lossless_round_trip() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
assert document.render() == SAMPLE
|
||||
|
||||
|
||||
def test_managed_block_detected() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
blocks = document.managed_blocks()
|
||||
assert list(blocks) == ["backup"]
|
||||
assert blocks["backup"].job_lines[0].expression == "0 2 * * *"
|
||||
assert blocks["backup"].job_lines[0].command == "/usr/local/bin/backup /srv/data"
|
||||
assert not document.has_malformed_markers()
|
||||
|
||||
|
||||
def test_environment_and_nickname_parsed() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
kinds = [entry.kind for entry in document.entries]
|
||||
assert kinds.count("env") == 3
|
||||
assert kinds.count("comment") == 1
|
||||
assert kinds.count("managed_begin") == 1
|
||||
assert kinds.count("managed_end") == 1
|
||||
jobs = [entry for entry in document.entries if entry.kind == "job"]
|
||||
assert jobs[0].expression == "15 7 * * *"
|
||||
assert jobs[1].expression == "@daily"
|
||||
|
||||
|
||||
def test_with_block_preserves_unrelated_content() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
updated = document.with_block("report", ["30 6 * * * /usr/local/bin/report"])
|
||||
assert "MAILTO=admin@example.net" in updated
|
||||
assert "15 7 * * * ~/bin/foo" in updated
|
||||
assert "0 2 * * * /usr/local/bin/backup /srv/data" in updated
|
||||
assert "# schedls:begin name=report" in updated
|
||||
reparsed = CrontabDocument(updated)
|
||||
assert set(reparsed.managed_blocks()) == {"backup", "report"}
|
||||
|
||||
|
||||
def test_with_block_conflict() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
with pytest.raises(ConflictError):
|
||||
document.with_block("backup", ["0 3 * * * /bin/true"])
|
||||
|
||||
|
||||
def test_without_block_removes_only_block() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
updated = document.without_block("backup")
|
||||
assert "backup" not in updated
|
||||
assert "MAILTO=admin@example.net" in updated
|
||||
assert "15 7 * * * ~/bin/foo" in updated
|
||||
assert "@daily /usr/local/bin/nightly" in updated
|
||||
|
||||
|
||||
def test_without_unknown_block_refused() -> None:
|
||||
document = CrontabDocument(SAMPLE)
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
document.without_block("nope")
|
||||
|
||||
|
||||
def test_malformed_marker_blocks_mutation() -> None:
|
||||
document = CrontabDocument("# schedls:begin name=broken\n0 2 * * * /bin/true\n")
|
||||
assert document.has_malformed_markers()
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
document.with_block("new", ["0 3 * * * /bin/true"])
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
document.without_block("broken")
|
||||
|
||||
|
||||
def test_empty_crontab() -> None:
|
||||
document = CrontabDocument("")
|
||||
assert document.render() == ""
|
||||
updated = document.with_block("x", ["0 0 * * * /bin/true"])
|
||||
assert updated.endswith("\n")
|
||||
assert "name=x" in updated
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from schedls.describe import describe_calendar, describe_cron, describe_schedule
|
||||
from schedls.models import Schedule, ScheduleKind
|
||||
|
||||
|
||||
def test_describe_calendar() -> None:
|
||||
assert describe_calendar("*-*-* 02:00:00") == "daily at 02:00"
|
||||
assert describe_calendar("Mon..Fri *-*-* 08:30:00") == "weekdays at 08:30"
|
||||
assert describe_calendar("Sun *-*-* 04:00:00") == "Sunday at 04:00"
|
||||
assert describe_calendar("*-*-01 06:00:00") == "monthly on day 1 at 06:00"
|
||||
assert describe_calendar("*-*-* *:00:00") is None
|
||||
|
||||
|
||||
def test_describe_cron() -> None:
|
||||
assert describe_cron("0 2 * * *") == "daily at 02:00"
|
||||
assert describe_cron("30 8 * * 1-5") == "weekdays at 08:30"
|
||||
assert describe_cron("0 4 * * 0") == "Sunday at 04:00"
|
||||
assert describe_cron("0 6 15 * *") == "monthly on day 15 at 06:00"
|
||||
assert describe_cron("0 4 1,15 * 5") is None
|
||||
assert describe_cron("@daily") is None
|
||||
|
||||
|
||||
def test_describe_schedule() -> None:
|
||||
assert describe_schedule(Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00")) == "daily at 02:00"
|
||||
assert describe_schedule(Schedule(ScheduleKind.CRON, "@reboot")) == "@reboot"
|
||||
multi = Schedule(ScheduleKind.CALENDAR, "A", ("A", "B"))
|
||||
assert describe_schedule(multi) == "A + B"
|
||||
@@ -0,0 +1,393 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.backends.base import CommandPlan, FileChange, MutationResult, Plan
|
||||
from schedls.backends.cron import CronBackend
|
||||
from schedls.backends.systemd import SystemdBackend
|
||||
from schedls.errors import InvalidScheduleError, OperationalError, SafetyRefusalError
|
||||
from schedls.interact import Interaction
|
||||
from schedls.models import (
|
||||
Backend,
|
||||
Command,
|
||||
JobSource,
|
||||
JobSpec,
|
||||
Schedule,
|
||||
ScheduledJob,
|
||||
ScheduleKind,
|
||||
Scope,
|
||||
SystemdDetails,
|
||||
)
|
||||
from schedls.operations import doctor as doctor_ops
|
||||
from schedls.operations import inspect as inspect_ops
|
||||
from schedls.operations import mutate as mutate_ops
|
||||
from schedls.output import Output
|
||||
|
||||
from ..conftest import FakeRunner
|
||||
|
||||
|
||||
def _job(name: str = "backup") -> ScheduledJob:
|
||||
return ScheduledJob(
|
||||
name=name,
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
managed=True,
|
||||
enabled=True,
|
||||
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
|
||||
command=Command(argv=("/usr/local/bin/backup",)),
|
||||
source=JobSource("systemd user timer"),
|
||||
)
|
||||
|
||||
|
||||
def _output() -> tuple[Output, io.StringIO]:
|
||||
stream = io.StringIO()
|
||||
return Output(color="never", stdout=stream), stream
|
||||
|
||||
|
||||
def test_render_list_table() -> None:
|
||||
output, stream = _output()
|
||||
inspect_ops.render_list(output, [_job(), _job("cleanup")], [])
|
||||
text = stream.getvalue()
|
||||
assert "NAME" in text and "SCHEDULE" in text
|
||||
assert "backup" in text and "cleanup" in text
|
||||
|
||||
|
||||
def test_render_list_json() -> None:
|
||||
output, stream = _output()
|
||||
output.json_mode = True
|
||||
inspect_ops.render_list(output, [_job()], ["note"])
|
||||
assert '"schema_version": 1' in stream.getvalue()
|
||||
|
||||
|
||||
def test_render_show() -> None:
|
||||
output, stream = _output()
|
||||
inspect_ops.render_show(output, _job())
|
||||
text = stream.getvalue()
|
||||
assert "Managed by schedls" in text
|
||||
assert "systemd user timer" in text
|
||||
|
||||
|
||||
def test_filter_jobs() -> None:
|
||||
jobs = [_job(), _job("other")]
|
||||
assert inspect_ops.filter_jobs(jobs, managed=True) == jobs
|
||||
assert inspect_ops.filter_jobs(jobs, managed=False) == []
|
||||
assert inspect_ops.filter_jobs(jobs, enabled=False) == []
|
||||
assert len(inspect_ops.filter_jobs(jobs, backend=Backend.SYSTEMD)) == 2
|
||||
|
||||
|
||||
def test_collect_reports_unavailable_backend() -> None:
|
||||
backend = SystemdBackend(FakeRunner())
|
||||
jobs, warnings = inspect_ops.collect([backend], [Scope.USER])
|
||||
assert jobs == []
|
||||
assert any("unavailable" in warning for warning in warnings)
|
||||
|
||||
|
||||
class _DummyBackend:
|
||||
name = "systemd"
|
||||
|
||||
def __init__(self, result: MutationResult) -> None:
|
||||
self.result = result
|
||||
self.applied = False
|
||||
|
||||
def apply(self, plan: Plan) -> MutationResult:
|
||||
self.applied = True
|
||||
return self.result
|
||||
|
||||
|
||||
def test_run_plan_dry_run(tmp_path) -> None:
|
||||
output, stream = _output()
|
||||
plan = Plan(backend="systemd", action="create")
|
||||
plan.files = [FileChange(str(tmp_path / "example.service"), "data")]
|
||||
plan.commands = [CommandPlan(("systemctl", "reload"), "reload")]
|
||||
backend = _DummyBackend(MutationResult(changed=True))
|
||||
result = mutate_ops.run_plan(backend, plan, output, Interaction(output=output), dry_run=True)
|
||||
assert result.dry_run is True
|
||||
assert backend.applied is False
|
||||
assert "No changes made." in stream.getvalue()
|
||||
|
||||
|
||||
def test_run_plan_confirms_and_applies() -> None:
|
||||
output, stream = _output()
|
||||
plan = Plan(backend="systemd", action="create")
|
||||
backend = _DummyBackend(MutationResult(changed=True, messages=["done"]))
|
||||
interaction = Interaction(output=output, assume_yes=True)
|
||||
result = mutate_ops.run_plan(backend, plan, output, interaction)
|
||||
assert backend.applied is True
|
||||
assert result.changed is True
|
||||
assert "done" in stream.getvalue()
|
||||
|
||||
|
||||
def test_run_plan_json_mode() -> None:
|
||||
output, stream = _output()
|
||||
output.json_mode = True
|
||||
plan = Plan(backend="systemd", action="create")
|
||||
backend = _DummyBackend(MutationResult(changed=True))
|
||||
mutate_ops.run_plan(backend, plan, output, Interaction(output=output, assume_yes=True))
|
||||
assert '"action": "create"' in stream.getvalue()
|
||||
|
||||
|
||||
def _doctor_runner() -> FakeRunner:
|
||||
def systemctl(argv, _input):
|
||||
if "is-system-running" in argv:
|
||||
return ("running\n", "")
|
||||
return ("", "")
|
||||
|
||||
def crontab(argv, _input):
|
||||
if "-V" in argv:
|
||||
return ("cronie 1.7.2\n", "")
|
||||
return ("", "")
|
||||
|
||||
return FakeRunner(
|
||||
handlers={
|
||||
"systemctl": systemctl,
|
||||
"crontab": crontab,
|
||||
"loginctl": lambda argv, _input: ("Linger=no\n", ""),
|
||||
},
|
||||
available={"systemctl", "systemd-analyze", "crontab", "loginctl"},
|
||||
)
|
||||
|
||||
|
||||
def test_doctor_human() -> None:
|
||||
runner = _doctor_runner()
|
||||
output, stream = _output()
|
||||
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
|
||||
text = stream.getvalue()
|
||||
assert "Cronie" in text
|
||||
assert "usable" in text
|
||||
|
||||
|
||||
def test_doctor_json() -> None:
|
||||
runner = _doctor_runner()
|
||||
output, stream = _output()
|
||||
output.json_mode = True
|
||||
doctor_ops.run_doctor(runner, output, SystemdBackend(runner), CronBackend(runner))
|
||||
assert '"result": "usable"' in stream.getvalue()
|
||||
|
||||
|
||||
def test_systemd_backend_discovery_parses_units() -> None:
|
||||
timer = (
|
||||
"# Managed by schedls\n"
|
||||
"# Name: backup\n"
|
||||
"[Unit]\nDescription=foo\n[Timer]\nOnCalendar=*-*-* 02:00:00\nPersistent=true\nUnit=schedls-backup.service\n"
|
||||
)
|
||||
service = '[Service]\nExecStart="/usr/local/bin/backup" "/srv/data"\n'
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
timer_path = os.path.join(tmp, "schedls-backup.timer")
|
||||
service_path = os.path.join(tmp, "schedls-backup.service")
|
||||
with open(timer_path, "w") as handle:
|
||||
handle.write(timer)
|
||||
with open(service_path, "w") as handle:
|
||||
handle.write(service)
|
||||
|
||||
def systemctl(argv, _input):
|
||||
if "is-system-running" in argv:
|
||||
return ("running\n", "")
|
||||
if "list-unit-files" in argv or "list-units" in argv:
|
||||
return ("schedls-backup.timer enabled\n", "")
|
||||
if "show" in argv:
|
||||
unit = argv[argv.index("show") + 1]
|
||||
if unit.endswith(".timer"):
|
||||
return (
|
||||
f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n"
|
||||
"UnitFileState=enabled\nActiveState=active\nSubState=waiting\n"
|
||||
"NextElapseUSecRealtime=1758700800000000\nTriggers=schedls-backup.service\n",
|
||||
"",
|
||||
)
|
||||
return (
|
||||
f"Id={unit}\nLoadState=loaded\nFragmentPath={service_path}\nResult=success\n",
|
||||
"",
|
||||
)
|
||||
return ("", "")
|
||||
|
||||
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
|
||||
backend = SystemdBackend(runner)
|
||||
jobs = backend.discover([Scope.USER])
|
||||
assert len(jobs) == 1
|
||||
job = jobs[0]
|
||||
assert job.name == "backup"
|
||||
assert job.managed is True
|
||||
assert job.schedule.expression == "*-*-* 02:00:00"
|
||||
assert job.command.argv == ("/usr/local/bin/backup", "/srv/data")
|
||||
assert job.enabled is True
|
||||
assert job.next_run is not None
|
||||
|
||||
|
||||
def test_plan_update_refuses_unmanaged() -> None:
|
||||
runner = FakeRunner()
|
||||
backend = SystemdBackend(runner)
|
||||
unmanaged = replace(_job("certbot"), managed=False)
|
||||
spec = JobSpec(
|
||||
name="certbot",
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/bin/true",)),
|
||||
calendar=("*-*-* 01:00:00",),
|
||||
)
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
backend.plan_update(unmanaged, spec)
|
||||
|
||||
|
||||
def _managed_job(name: str = "backup", *, scope: Scope = Scope.USER, service_unit: str | None = None) -> ScheduledJob:
|
||||
return replace(
|
||||
_job(name),
|
||||
scope=scope,
|
||||
systemd=SystemdDetails(
|
||||
timer_unit=f"schedls-{name}.timer",
|
||||
service_unit=service_unit or f"schedls-{name}.service",
|
||||
on_calendar=("*-*-* 02:00:00",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _spec(name: str = "backup") -> JobSpec:
|
||||
return JobSpec(
|
||||
name=name,
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/bin/true",)),
|
||||
calendar=("*-*-* 03:00:00",),
|
||||
)
|
||||
|
||||
|
||||
def _analyze_runner() -> FakeRunner:
|
||||
def analyze(argv, _input):
|
||||
if "calendar" in argv:
|
||||
return ("Normalized form: *-*-* 03:00:00\n", "")
|
||||
return ("", "")
|
||||
|
||||
return FakeRunner(handlers={"systemd-analyze": analyze}, available={"systemd-analyze"})
|
||||
|
||||
|
||||
def test_plan_update_derives_unit_names(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
backend = SystemdBackend(_analyze_runner())
|
||||
job = _managed_job(service_unit="../../../../tmp/evil.service")
|
||||
plan = backend.plan_update(job, _spec())
|
||||
expected_dir = os.path.join(str(tmp_path), "systemd", "user")
|
||||
assert sorted(change.path for change in plan.files) == [
|
||||
os.path.join(expected_dir, "schedls-backup.service"),
|
||||
os.path.join(expected_dir, "schedls-backup.timer"),
|
||||
]
|
||||
assert "evil" not in " ".join(change.path for change in plan.files)
|
||||
|
||||
|
||||
def test_verify_units_rejects_traversal() -> None:
|
||||
backend = SystemdBackend(_analyze_runner())
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
backend._verify_units({"../../evil.service": "content"})
|
||||
|
||||
|
||||
def test_unit_dir_rejects_relative_xdg(monkeypatch) -> None:
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "relative")
|
||||
backend = SystemdBackend(FakeRunner())
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
backend._unit_dir(Scope.USER)
|
||||
|
||||
|
||||
def test_plan_update_requires_scope(monkeypatch) -> None:
|
||||
monkeypatch.setattr(os, "geteuid", lambda: 1000)
|
||||
backend = SystemdBackend(_analyze_runner())
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
backend.plan_update(_managed_job(scope=Scope.SYSTEM), _spec())
|
||||
|
||||
|
||||
def test_validate_spec_rejects_unsafe_calendar() -> None:
|
||||
backend = SystemdBackend(FakeRunner())
|
||||
spec = replace(_spec(), calendar=("-x",))
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
backend._validate_spec(spec)
|
||||
|
||||
|
||||
def test_validate_spec_rejects_unsafe_jitter() -> None:
|
||||
backend = SystemdBackend(_analyze_runner())
|
||||
for jitter in ("5min\nExecStart=/bin/true", "-5min"):
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
backend._validate_spec(replace(_spec(), jitter=jitter))
|
||||
|
||||
|
||||
def test_apply_rolls_back_on_keyboard_interrupt(tmp_path) -> None:
|
||||
target = tmp_path / "schedls-backup.service"
|
||||
raised = []
|
||||
|
||||
def systemctl(argv, _input):
|
||||
if not raised:
|
||||
raised.append(True)
|
||||
raise KeyboardInterrupt
|
||||
return ("", "")
|
||||
|
||||
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
|
||||
backend = SystemdBackend(runner)
|
||||
plan = Plan(backend="systemd", action="update")
|
||||
plan.files = [FileChange(str(target), "content", mode=0o600)]
|
||||
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
|
||||
plan.payload = {"scope": Scope.USER, "snapshots": {}}
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
backend.apply(plan)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_rollback_disables_timer_on_create_failure(tmp_path) -> None:
|
||||
target = tmp_path / "schedls-backup.timer"
|
||||
|
||||
def systemctl(argv, _input):
|
||||
if "daemon-reload" in argv:
|
||||
return (1, "", "boom")
|
||||
return ("", "")
|
||||
|
||||
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
|
||||
backend = SystemdBackend(runner)
|
||||
plan = Plan(backend="systemd", action="create")
|
||||
plan.files = [FileChange(str(target), "content", mode=0o600)]
|
||||
plan.commands = [CommandPlan(("systemctl", "daemon-reload"), "reload")]
|
||||
plan.payload = {"scope": Scope.USER, "timer_unit": "schedls-backup.timer", "snapshots": {}}
|
||||
with pytest.raises(OperationalError):
|
||||
backend.apply(plan)
|
||||
assert any("disable" in call[0] for call in runner.calls)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_logs_rejects_unsafe_service_unit() -> None:
|
||||
backend = SystemdBackend(FakeRunner(available={"journalctl"}))
|
||||
job = _managed_job(service_unit="--output=json.service")
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
backend.logs(job, lines=5, since=None)
|
||||
|
||||
|
||||
def test_logs_returns_journal_text() -> None:
|
||||
runner = FakeRunner(
|
||||
handlers={"journalctl": lambda argv, _input: ("hello\n", "")},
|
||||
available={"journalctl"},
|
||||
)
|
||||
backend = SystemdBackend(runner)
|
||||
assert backend.logs(_managed_job(), lines=5, since="2 hours ago") == "hello\n"
|
||||
argv = runner.calls[0][0]
|
||||
assert "--since" in argv and "--lines=5" in argv
|
||||
|
||||
|
||||
def test_prefix_named_unit_without_marker_is_unmanaged(tmp_path) -> None:
|
||||
timer_path = tmp_path / "schedls-backup.timer"
|
||||
timer_path.write_text("[Unit]\n[Timer]\nOnCalendar=*-*-* 02:00:00\n")
|
||||
|
||||
def systemctl(argv, _input):
|
||||
if "is-system-running" in argv:
|
||||
return ("running\n", "")
|
||||
if "list-unit-files" in argv or "list-units" in argv:
|
||||
return ("schedls-backup.timer enabled\n", "")
|
||||
if "show" in argv:
|
||||
unit = argv[argv.index("show") + 1]
|
||||
if unit.endswith(".timer"):
|
||||
return (f"Id={unit}\nLoadState=loaded\nFragmentPath={timer_path}\n", "")
|
||||
return ("", "")
|
||||
return ("", "")
|
||||
|
||||
runner = FakeRunner(handlers={"systemctl": systemctl}, available={"systemctl"})
|
||||
jobs = SystemdBackend(runner).discover([Scope.USER])
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].managed is False
|
||||
assert jobs[0].name == "schedls-backup"
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from schedls.models import (
|
||||
Backend,
|
||||
Command,
|
||||
CronDetails,
|
||||
JobSource,
|
||||
Schedule,
|
||||
ScheduledJob,
|
||||
ScheduleKind,
|
||||
Scope,
|
||||
SystemdDetails,
|
||||
)
|
||||
from schedls.output import Output, job_to_dict, jobs_document, sanitize_text
|
||||
|
||||
|
||||
def _systemd_job() -> ScheduledJob:
|
||||
return ScheduledJob(
|
||||
name="backup",
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
managed=True,
|
||||
enabled=True,
|
||||
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00", ("*-*-* 02:00:00",)),
|
||||
command=Command(argv=("/usr/local/bin/backup", "/srv/data")),
|
||||
source=JobSource("systemd user timer", path="/home/u/.config/systemd/user/schedls-backup.timer"),
|
||||
next_run=datetime(2026, 9, 25, 2, 0, tzinfo=UTC),
|
||||
systemd=SystemdDetails(
|
||||
timer_unit="schedls-backup.timer",
|
||||
service_unit="schedls-backup.service",
|
||||
persistent=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _cron_job() -> ScheduledJob:
|
||||
return ScheduledJob(
|
||||
name="cron-3",
|
||||
backend=Backend.CRON,
|
||||
scope=Scope.USER,
|
||||
managed=False,
|
||||
enabled=None,
|
||||
schedule=Schedule(ScheduleKind.CRON, "0 4 * * 0"),
|
||||
command=Command(raw="/usr/local/bin/cleanup"),
|
||||
source=JobSource("current user's crontab", line=3),
|
||||
cron=CronDetails(expression="0 4 * * 0", line=3),
|
||||
)
|
||||
|
||||
|
||||
def test_job_to_dict_systemd() -> None:
|
||||
data = job_to_dict(_systemd_job())
|
||||
assert data["name"] == "backup"
|
||||
assert data["backend"] == "systemd"
|
||||
assert data["managed"] is True
|
||||
assert data["next_run"].endswith("+00:00")
|
||||
assert data["systemd"]["persistent"] is True
|
||||
|
||||
|
||||
def test_job_to_dict_cron_missing_data_is_null() -> None:
|
||||
data = job_to_dict(_cron_job())
|
||||
assert data["next_run"] is None
|
||||
assert data["last_run"] is None
|
||||
assert data["enabled"] is None
|
||||
|
||||
|
||||
def test_jobs_document_schema_and_no_ansi() -> None:
|
||||
document = jobs_document([_systemd_job(), _cron_job()], ["a warning"])
|
||||
assert document["schema_version"] == 1
|
||||
assert len(document["jobs"]) == 2
|
||||
dumped = json.dumps(document)
|
||||
assert "\x1b" not in dumped
|
||||
assert "backup" in dumped
|
||||
|
||||
|
||||
def test_output_table_and_key_values() -> None:
|
||||
stream = io.StringIO()
|
||||
output = Output(json_mode=False, color="never", stdout=stream)
|
||||
output.table(["NAME", "BACKEND"], [["backup", "systemd"], ["cleanup", "cron"]])
|
||||
text = stream.getvalue()
|
||||
assert "NAME" in text
|
||||
assert "backup" in text
|
||||
assert "\x1b" not in text
|
||||
|
||||
|
||||
def test_output_json_mode() -> None:
|
||||
stream = io.StringIO()
|
||||
output = Output(json_mode=True, color="never", stdout=stream)
|
||||
output.emit_json({"schema_version": 1, "jobs": []})
|
||||
assert json.loads(stream.getvalue())["schema_version"] == 1
|
||||
|
||||
|
||||
def test_sanitize_text_escapes_control_characters() -> None:
|
||||
text = "a\x1b[31mred\x1b[0m\nb\tc\x00d\x7f"
|
||||
assert sanitize_text(text) == "a\\x1b[31mred\\x1b[0m\nb\tc\\x00d\\x7f"
|
||||
|
||||
|
||||
def test_output_write_is_verbatim() -> None:
|
||||
stream = io.StringIO()
|
||||
output = Output(color="never", stdout=stream)
|
||||
output.write("line one\nline two")
|
||||
assert stream.getvalue() == "line one\nline two"
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.cli import _spec_for_edit, _spec_for_new, _wizard_edit, _wizard_new, build_parser
|
||||
from schedls.errors import ConfirmationRequiredError, InvalidScheduleError
|
||||
from schedls.models import (
|
||||
Backend,
|
||||
Command,
|
||||
JobSource,
|
||||
Schedule,
|
||||
ScheduledJob,
|
||||
ScheduleKind,
|
||||
Scope,
|
||||
SystemdDetails,
|
||||
)
|
||||
from schedls.output import Output
|
||||
from schedls.prompt import Prompter
|
||||
|
||||
|
||||
class FakeInput:
|
||||
def __init__(self, answers: list[str]) -> None:
|
||||
self.answers = list(answers)
|
||||
self.prompts: list[str] = []
|
||||
|
||||
def __call__(self, label: str) -> str:
|
||||
self.prompts.append(label)
|
||||
if not self.answers:
|
||||
raise EOFError
|
||||
return self.answers.pop(0)
|
||||
|
||||
|
||||
class FakeStdin:
|
||||
def __init__(self, *, isatty: bool) -> None:
|
||||
self._isatty = isatty
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return self._isatty
|
||||
|
||||
|
||||
def make_prompter(answers: list[str], *, calendar_validator=None) -> tuple[Prompter, FakeInput]:
|
||||
fake = FakeInput(answers)
|
||||
output = Output(stdout=io.StringIO(), stderr=io.StringIO())
|
||||
return Prompter(output=output, input_fn=fake, calendar_validator=calendar_validator), fake
|
||||
|
||||
|
||||
def _systemd_job() -> ScheduledJob:
|
||||
return ScheduledJob(
|
||||
name="backup",
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
managed=True,
|
||||
enabled=True,
|
||||
schedule=Schedule(ScheduleKind.CALENDAR, "*-*-* 02:00:00"),
|
||||
command=Command(argv=("/usr/local/bin/backup",)),
|
||||
source=JobSource(detail="systemd user timer"),
|
||||
systemd=SystemdDetails(on_calendar=("*-*-* 02:00:00",)),
|
||||
)
|
||||
|
||||
|
||||
def test_text_requires_a_value() -> None:
|
||||
prompter, fake = make_prompter(["", "backup"])
|
||||
assert prompter.text("Name") == "backup"
|
||||
assert len(fake.prompts) == 2
|
||||
|
||||
|
||||
def test_text_uses_default_on_blank() -> None:
|
||||
prompter, _ = make_prompter([""])
|
||||
assert prompter.text("Name", default="backup") == "backup"
|
||||
|
||||
|
||||
def test_optional_text() -> None:
|
||||
prompter, _ = make_prompter(["", ""])
|
||||
assert prompter.optional_text("Jitter") is None
|
||||
assert prompter.optional_text("Jitter", default="5min") == "5min"
|
||||
|
||||
|
||||
def test_yes_no_rejects_unknown_answers() -> None:
|
||||
prompter, _ = make_prompter(["maybe", "yes"])
|
||||
assert prompter.yes_no("Continue?") is True
|
||||
|
||||
|
||||
def test_choice_by_number_and_default() -> None:
|
||||
prompter, _ = make_prompter(["9", "2", ""])
|
||||
options = [("daily", "Daily"), ("weekly", "Weekly")]
|
||||
assert prompter.choice("Schedule:", options, default="daily") == "weekly"
|
||||
assert prompter.choice("Schedule:", options, default="daily") == "daily"
|
||||
|
||||
|
||||
def test_command_builds_argv() -> None:
|
||||
prompter, _ = make_prompter(["n", "/usr/bin/echo hello world"])
|
||||
command = prompter.command()
|
||||
assert command.shell is False
|
||||
assert command.argv == ("/usr/bin/echo", "hello", "world")
|
||||
|
||||
|
||||
def test_command_shell_mode() -> None:
|
||||
prompter, _ = make_prompter(["y", "echo hi | cat"])
|
||||
command = prompter.command()
|
||||
assert command.shell is True
|
||||
assert command.raw == "echo hi | cat"
|
||||
|
||||
|
||||
def test_environment_collects_valid_entries() -> None:
|
||||
prompter, _ = make_prompter(["A=1", "1BAD=2", "B=x=y", ""])
|
||||
assert prompter.environment() == ["A=1", "B=x=y"]
|
||||
|
||||
|
||||
def test_schedule_daily_reprompts_on_bad_time() -> None:
|
||||
prompter, _ = make_prompter(["", "25:00", "02:30"])
|
||||
assert prompter.schedule(Backend.SYSTEMD) == {"daily": "02:30"}
|
||||
|
||||
|
||||
def test_schedule_weekly_and_monthly() -> None:
|
||||
prompter, _ = make_prompter(["3", "Mon", "02:00"])
|
||||
assert prompter.schedule(Backend.SYSTEMD) == {"weekly": ["Mon", "02:00"]}
|
||||
prompter, _ = make_prompter(["4", "15", "06:00"])
|
||||
assert prompter.schedule(Backend.SYSTEMD) == {"monthly": ["15", "06:00"]}
|
||||
|
||||
|
||||
def test_schedule_custom_systemd_validates() -> None:
|
||||
def validator(expression: str) -> None:
|
||||
if expression != "daily":
|
||||
raise InvalidScheduleError("bad expression")
|
||||
|
||||
prompter, _ = make_prompter(["5", "bogus", "daily"], calendar_validator=validator)
|
||||
assert prompter.schedule(Backend.SYSTEMD) == {"calendar": ["daily"]}
|
||||
|
||||
|
||||
def test_schedule_custom_cron() -> None:
|
||||
prompter, _ = make_prompter(["5", "0 4 * * *"])
|
||||
assert prompter.schedule(Backend.CRON) == {"cron_expr": "0 4 * * *"}
|
||||
|
||||
|
||||
def test_require_terminal_rejects_non_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=False))
|
||||
prompter, _ = make_prompter([])
|
||||
with pytest.raises(ConfirmationRequiredError):
|
||||
prompter.require_terminal()
|
||||
|
||||
|
||||
def test_eof_raises_confirmation_required() -> None:
|
||||
prompter, _ = make_prompter([])
|
||||
with pytest.raises(ConfirmationRequiredError):
|
||||
prompter.text("Name")
|
||||
|
||||
|
||||
def test_wizard_new_systemd(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||
args = build_parser().parse_args(["new", "-i"])
|
||||
answers = ["backup", "", "n", "/usr/local/bin/backup /srv/data", "", "02:00", "n"]
|
||||
prompter, _ = make_prompter(answers)
|
||||
args, tail = _wizard_new(args, [], prompter)
|
||||
spec = _spec_for_new(args, tail)
|
||||
assert spec.name == "backup"
|
||||
assert spec.backend is Backend.SYSTEMD
|
||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||
assert spec.command.argv == ("/usr/local/bin/backup", "/srv/data")
|
||||
|
||||
|
||||
def test_wizard_new_cron(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||
args = build_parser().parse_args(["new", "-i"])
|
||||
answers = ["cleanup", "2", "n", "/usr/local/bin/cleanup", "", "04:00"]
|
||||
prompter, _ = make_prompter(answers)
|
||||
args, tail = _wizard_new(args, [], prompter)
|
||||
spec = _spec_for_new(args, tail)
|
||||
assert spec.backend is Backend.CRON
|
||||
assert spec.cron_expression == "0 4 * * *"
|
||||
|
||||
|
||||
def test_wizard_new_skips_provided_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||
args = build_parser().parse_args(["new", "backup", "-i", "--timer", "--daily", "02:00"])
|
||||
prompter, fake = make_prompter(["n"])
|
||||
args, tail = _wizard_new(args, ["/bin/true"], prompter)
|
||||
spec = _spec_for_new(args, tail)
|
||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||
assert fake.prompts == ["Set advanced timer options? [y/N] "]
|
||||
|
||||
|
||||
def test_wizard_edit_changes_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||
job = _systemd_job()
|
||||
args = build_parser().parse_args(["edit", "backup", "-i"])
|
||||
answers = ["n", "y", "", "03:00", "n"]
|
||||
prompter, _ = make_prompter(answers)
|
||||
args, tail = _wizard_edit(job, args, [], prompter)
|
||||
spec = _spec_for_edit(job, args, tail)
|
||||
assert spec.calendar == ("*-*-* 03:00:00",)
|
||||
|
||||
|
||||
def test_wizard_edit_keeps_changes_optional(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", FakeStdin(isatty=True))
|
||||
job = _systemd_job()
|
||||
args = build_parser().parse_args(["edit", "backup", "-i"])
|
||||
prompter, _ = make_prompter(["n", "n", "n"])
|
||||
args, tail = _wizard_edit(job, args, [], prompter)
|
||||
spec = _spec_for_edit(job, args, tail)
|
||||
assert spec.calendar == ("*-*-* 02:00:00",)
|
||||
assert tail == []
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.errors import InvalidScheduleError, SafetyRefusalError
|
||||
from schedls.models import Command
|
||||
from schedls.renderers import cron as renderer
|
||||
|
||||
NASTY_ARGS = [
|
||||
"plain",
|
||||
"a b",
|
||||
"a\tb",
|
||||
'a"b',
|
||||
"a'b",
|
||||
"a\\b",
|
||||
"a\\\\b",
|
||||
"$HOME",
|
||||
"$(touch /tmp/pwned)",
|
||||
"`touch /tmp/pwned`",
|
||||
"%",
|
||||
"100%",
|
||||
"a%b",
|
||||
"a\\%b",
|
||||
"a\\\\%b",
|
||||
"",
|
||||
" leading",
|
||||
"trailing ",
|
||||
"semi;colon",
|
||||
"pipe|cmd",
|
||||
"new&line",
|
||||
">out",
|
||||
"unicode-\u00e9\u4e2d\U0001f600",
|
||||
]
|
||||
|
||||
|
||||
def cron_resolve(text: str) -> str:
|
||||
"""Emulate Cronie's ``%`` processing of a command field."""
|
||||
out: list[str] = []
|
||||
index = 0
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if char == "\\" and index + 1 < len(text) and text[index + 1] == "%":
|
||||
out.append("%")
|
||||
index += 2
|
||||
continue
|
||||
if char == "%":
|
||||
out.append("\n")
|
||||
index += 1
|
||||
continue
|
||||
out.append(char)
|
||||
index += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def test_escape_percent() -> None:
|
||||
assert renderer.escape_percent("100%") == "100\\%"
|
||||
assert renderer.escape_percent("a\\%b") == "a\\\\%b"
|
||||
|
||||
|
||||
def test_render_command_argv() -> None:
|
||||
rendered = renderer.render_command(Command(argv=("/usr/local/bin/backup", "/srv/My Data")))
|
||||
assert rendered == "/usr/local/bin/backup '/srv/My Data'"
|
||||
|
||||
|
||||
def test_render_command_shell() -> None:
|
||||
rendered = renderer.render_command(Command(shell=True, raw="echo 50% > /tmp/x"))
|
||||
assert rendered == "echo 50\\% > /tmp/x"
|
||||
|
||||
|
||||
def test_expression_percent_not_double_escaped() -> None:
|
||||
line = renderer.render_line("0 2 * * *", Command(argv=("/bin/echo", "a%b")))
|
||||
assert line == "0 2 * * * /bin/echo a\\%b"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
["", "0 2 * *", "0 2 * * * *", "60 2 * * *", "0 25 * * *", "0 2 32 * *", "@bogus", "a b c d e"],
|
||||
)
|
||||
def test_invalid_expressions(bad: str) -> None:
|
||||
with pytest.raises(InvalidScheduleError):
|
||||
renderer.validate_expression(bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"good",
|
||||
["* * * * *", "0 2 * * *", "*/5 * * * *", "0 2 * * 1-5", "0 2 1,15 * 5", "@daily", "@reboot"],
|
||||
)
|
||||
def test_valid_expressions(good: str) -> None:
|
||||
assert renderer.validate_expression(good)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", NASTY_ARGS)
|
||||
def test_argv_round_trip_through_shell(tmp_path, arg: str) -> None:
|
||||
dump = tmp_path / "dump.py"
|
||||
dump.write_text("import json,sys\nopen(sys.argv[1],'w').write(json.dumps(sys.argv[2:]))\n")
|
||||
out = tmp_path / "out.json"
|
||||
command = Command(argv=(sys.executable, str(dump), str(out), arg))
|
||||
serialized = renderer.render_command(command)
|
||||
resolved = cron_resolve(serialized)
|
||||
subprocess.run(["/bin/sh", "-c", resolved], check=True)
|
||||
received = json.loads(out.read_text())
|
||||
assert received == [arg]
|
||||
|
||||
|
||||
def test_no_command_injection(tmp_path) -> None:
|
||||
marker = tmp_path / "pwned"
|
||||
command = Command(argv=("/bin/true", f"x; touch {marker}", f"$(touch {marker})", f"`touch {marker}`"))
|
||||
resolved = cron_resolve(renderer.render_command(command))
|
||||
subprocess.run(["/bin/sh", "-c", resolved], check=True)
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_newline_rejected() -> None:
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
renderer.render_command(Command(argv=("/bin/echo", "a\nb")))
|
||||
|
||||
|
||||
def test_managed_block() -> None:
|
||||
block = renderer.render_block("backup", ["0 2 * * * /bin/true"])
|
||||
assert block == ("# schedls:begin name=backup\n0 2 * * * /bin/true\n# schedls:end name=backup\n")
|
||||
|
||||
|
||||
def test_shlex_reference() -> None:
|
||||
assert shlex.quote("a b") == "'a b'"
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls.errors import SafetyRefusalError
|
||||
from schedls.models import Backend, Command, JobSpec, Scope
|
||||
from schedls.renderers import systemd as renderer
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "systemd"
|
||||
|
||||
NASTY_ARGS = [
|
||||
"plain",
|
||||
"a b",
|
||||
"a\tb",
|
||||
'a"b',
|
||||
"a'b",
|
||||
"a\\b",
|
||||
"a\\\\b",
|
||||
"$HOME",
|
||||
"$$",
|
||||
"$(touch /tmp/pwned)",
|
||||
"`touch /tmp/pwned`",
|
||||
"%n",
|
||||
"%%",
|
||||
"%i",
|
||||
"",
|
||||
" leading",
|
||||
"trailing ",
|
||||
"semi;colon",
|
||||
"pipe|cmd",
|
||||
"new&line",
|
||||
"unicode-\u00e9\u4e2d\U0001f600",
|
||||
"-dash",
|
||||
"@at",
|
||||
"!bang",
|
||||
]
|
||||
|
||||
|
||||
def test_quote_basic() -> None:
|
||||
assert renderer.quote_systemd_arg("plain") == '"plain"'
|
||||
assert renderer.quote_systemd_arg("a b") == '"a b"'
|
||||
assert renderer.quote_systemd_arg("") == '""'
|
||||
assert renderer.quote_systemd_arg("$HOME") == '"$$HOME"'
|
||||
assert renderer.quote_systemd_arg("%n") == '"%%n"'
|
||||
assert renderer.quote_systemd_arg('a"b') == '"a\\"b"'
|
||||
assert renderer.quote_systemd_arg("a\\b") == '"a\\\\b"'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", NASTY_ARGS)
|
||||
def test_argv_round_trip(arg: str) -> None:
|
||||
command = Command(argv=(arg,))
|
||||
rendered = renderer.render_exec_start(command)
|
||||
value = rendered.split("=", 1)[1]
|
||||
parsed = renderer.parse_exec_start(value)
|
||||
assert parsed.argv == (arg,)
|
||||
|
||||
|
||||
def test_multi_argv_round_trip() -> None:
|
||||
command = Command(argv=tuple(NASTY_ARGS))
|
||||
value = renderer.render_exec_start(command).split("=", 1)[1]
|
||||
assert renderer.parse_exec_start(value).argv == tuple(NASTY_ARGS)
|
||||
|
||||
|
||||
def test_shell_mode_round_trip() -> None:
|
||||
command = Command(shell=True, raw="generate-report | gzip > /srv/report.gz")
|
||||
value = renderer.render_exec_start(command).split("=", 1)[1]
|
||||
parsed = renderer.parse_exec_start(value)
|
||||
assert parsed.shell is True
|
||||
assert parsed.raw == "generate-report | gzip > /srv/report.gz"
|
||||
|
||||
|
||||
def test_shell_mode_quotes_script() -> None:
|
||||
command = Command(shell=True, raw="echo $HOME > /tmp/x")
|
||||
rendered = renderer.render_exec_start(command)
|
||||
assert rendered.startswith('ExecStart=/bin/sh -c "')
|
||||
assert "$$HOME" in rendered
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["a\nb", "a\rb", "a\x00b"])
|
||||
def test_control_characters_rejected(bad: str) -> None:
|
||||
with pytest.raises(SafetyRefusalError):
|
||||
renderer.render_exec_start(Command(argv=(bad,)))
|
||||
|
||||
|
||||
def test_golden_service() -> None:
|
||||
spec = JobSpec(
|
||||
name="backup",
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/usr/local/bin/backup", "/srv/data")),
|
||||
calendar=("*-*-* 02:00:00",),
|
||||
persistent=True,
|
||||
)
|
||||
files = renderer.render_units(spec, service_unit="schedls-backup.service", timer_unit="schedls-backup.timer")
|
||||
assert files["schedls-backup.service"] == (FIXTURES / "basic.service").read_text()
|
||||
assert files["schedls-backup.timer"] == (FIXTURES / "basic.timer").read_text()
|
||||
|
||||
|
||||
def test_timer_multiple_calendars() -> None:
|
||||
spec = JobSpec(
|
||||
name="multi",
|
||||
backend=Backend.SYSTEMD,
|
||||
scope=Scope.USER,
|
||||
command=Command(argv=("/bin/true",)),
|
||||
calendar=("Mon..Fri 02:00:00", "Sat,Sun 04:00:00"),
|
||||
)
|
||||
timer = renderer.render_timer(spec, "schedls-multi.timer", "schedls-multi.service")
|
||||
assert "OnCalendar=Mon..Fri 02:00:00" in timer
|
||||
assert "OnCalendar=Sat,Sun 04:00:00" in timer
|
||||
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
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")
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from schedls import timefmt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "seconds"),
|
||||
[
|
||||
("1s", 1.0),
|
||||
("30m", 1800.0),
|
||||
("1h", 3600.0),
|
||||
("2h30m", 9000.0),
|
||||
("1d", 86400.0),
|
||||
("1w", 604800.0),
|
||||
("500ms", 0.5),
|
||||
("1min", 60.0),
|
||||
],
|
||||
)
|
||||
def test_duration_seconds(value: str, seconds: float) -> None:
|
||||
assert timefmt.duration_seconds(value) == pytest.approx(seconds)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "1x", "h", "1h2x"])
|
||||
def test_invalid_duration(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
timefmt.duration_seconds(bad)
|
||||
assert not timefmt.is_valid_duration(bad)
|
||||
|
||||
|
||||
def test_parse_microseconds() -> None:
|
||||
parsed = timefmt.parse_systemd_timestamp("1758700800000000")
|
||||
assert parsed is not None
|
||||
assert parsed.year == 2025
|
||||
|
||||
|
||||
def test_parse_pretty() -> None:
|
||||
parsed = timefmt.parse_systemd_timestamp("Thu 2026-09-24 06:49:28 BST")
|
||||
assert parsed is not None
|
||||
assert (parsed.year, parsed.month, parsed.day) == (2026, 9, 24)
|
||||
assert parsed.tzinfo is not None
|
||||
|
||||
|
||||
def test_parse_placeholders() -> None:
|
||||
assert timefmt.parse_systemd_timestamp("") is None
|
||||
assert timefmt.parse_systemd_timestamp("n/a") is None
|
||||
assert timefmt.parse_systemd_timestamp("0") is None
|
||||
|
||||
|
||||
def test_format_datetime_round_trip_zone() -> None:
|
||||
dt = datetime(2026, 9, 25, 2, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
||||
text = timefmt.format_datetime(dt)
|
||||
assert text.startswith("Fri 25 Sep 2026 02:00:00")
|
||||
|
||||
|
||||
def test_format_short_relative() -> None:
|
||||
now = datetime(2026, 9, 24, 12, 0, tzinfo=UTC)
|
||||
today = datetime(2026, 9, 24, 18, 0, tzinfo=UTC)
|
||||
tomorrow = datetime(2026, 9, 25, 3, 0, tzinfo=UTC)
|
||||
assert timefmt.format_short(today, now=now, utc=True) == "today 18:00"
|
||||
assert timefmt.format_short(tomorrow, now=now, utc=True) == "tomorrow 03:00"
|
||||
|
||||
|
||||
def test_isoformat_has_offset() -> None:
|
||||
dt = datetime(2026, 9, 25, 2, 0, tzinfo=UTC)
|
||||
assert timefmt.isoformat(dt).endswith("+00:00")
|
||||
Reference in New Issue
Block a user