- daemon_conf.c/server.c/test_daemon_conf.c: const-qualify parse/loop pointers; scope user_path static inside its block (clears the 9-wave-A cppcheck findings) - config.c receive_daemon_module: reject invalid/over-long wire module names (> DAEMON_MAX_MODULE_NAME) with a clean STATUS_ERROR; client side already enforced via daemon_module_name_valid in config_parse_daemon_dest - server.c daemonize: chdir(/) and umask(0) so module paths resolve from / and config-requested file modes are honored; PROTOCOL_VERSION stays 2.15.0 - test_daemon.py: confinement (read-only/unknown no-write anywhere), module-less and dot-dot destination refusal, real daemon_detach double-fork path
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""Daemon mode (--daemon + module config + host::module/path destinations) tests.
|
|
|
|
These exercise the Wave A daemon foundation end to end: a fastsync-server
|
|
started with --daemon reads a FastSync-native module config file, the client
|
|
asks for a module with a host::module/path destination, and the transfer lands
|
|
in the configured module root only. Read-only modules, unknown modules, and
|
|
auth-required modules are all refused cleanly before any data moves.
|
|
"""
|
|
import glob
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from common import (
|
|
TEST_DATA_DIR,
|
|
CLIENT_CMD,
|
|
SERVER_CMD,
|
|
generate_test_files,
|
|
run_client,
|
|
get_dest_received_dir,
|
|
verify_transfer,
|
|
_find_free_port,
|
|
_wait_for_port,
|
|
)
|
|
|
|
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "daemon_source")
|
|
MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
|
|
FILES_MODULE = os.path.join(MODULE_ROOT, "files")
|
|
READONLY_MODULE = os.path.join(MODULE_ROOT, "readonly")
|
|
AUTH_MODULE = os.path.join(MODULE_ROOT, "auth")
|
|
CONF_FILE = os.path.join(TEST_DATA_DIR, "fastsyncd.conf")
|
|
DETACH_MODULE = os.path.join(MODULE_ROOT, "detach")
|
|
DETACH_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.conf")
|
|
DETACH_PORT = None
|
|
|
|
|
|
def _kill_by_cmdline_marker(marker):
|
|
"""Send SIGTERM to every running process whose cmdline contains `marker`
|
|
(used to clean up the double-forked --daemon, which is orphaned to init and
|
|
no longer a child of the test's own process). Portable over /proc so the
|
|
tests do not depend on pgrep being present."""
|
|
for proc_path in glob.glob("/proc/[0-9]*/cmdline"):
|
|
try:
|
|
with open(proc_path, "rb") as f:
|
|
data = f.read()
|
|
except OSError:
|
|
continue
|
|
if marker.encode() in data:
|
|
try:
|
|
os.kill(int(proc_path.split("/")[2]), signal.SIGTERM)
|
|
except (ProcessLookupError, ValueError):
|
|
pass
|
|
time.sleep(0.5)
|
|
|
|
|
|
class DaemonManager:
|
|
"""Boots one fastsync-server --daemon from a config file and tears it down
|
|
(including its accept-loop children) on exit."""
|
|
|
|
def __init__(self):
|
|
self._proc = None
|
|
self._port = None
|
|
|
|
def start(self, config_path, port_override=None):
|
|
self.stop()
|
|
# When no override is given the daemon binds the config file's `port`
|
|
# (the plain config-port path); with an override the --dparam path.
|
|
self._port = port_override if port_override is not None else _config_port(config_path)
|
|
cmd = (SERVER_CMD + ["--daemon", "--config", config_path, "--allow-unauthenticated",
|
|
"--no-detach"])
|
|
if port_override is not None:
|
|
cmd += ["--dparam", f"port={port_override}"]
|
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd.log")
|
|
log = open(log_path, "w")
|
|
self._proc = subprocess.Popen(
|
|
cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
_wait_for_port(self._port, timeout=10)
|
|
|
|
def stop(self):
|
|
if self._proc:
|
|
try:
|
|
os.killpg(self._proc.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
self._proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(self._proc.pid, signal.SIGKILL)
|
|
self._proc.wait()
|
|
self._proc = None
|
|
|
|
@property
|
|
def port(self):
|
|
return self._port
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
self.stop()
|
|
|
|
def __del__(self):
|
|
self.stop()
|
|
|
|
|
|
def _config_port(config_path):
|
|
"""Read the explicit `port = N` line out of the daemon config file."""
|
|
with open(config_path) as f:
|
|
for line in f:
|
|
stripped = line.strip()
|
|
if stripped.startswith("port") and "=" in stripped:
|
|
return int(stripped.split("=", 1)[1].strip())
|
|
raise RuntimeError(f"no port= in {config_path}")
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def daemon_env():
|
|
for d in (MODULE_ROOT, FILES_MODULE, READONLY_MODULE, AUTH_MODULE, DETACH_MODULE):
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
os.makedirs(d, exist_ok=True)
|
|
generate_test_files(SOURCE_DIR, full=False)
|
|
|
|
# The config's port is a free port chosen per worker; the `daemon` fixture
|
|
# boots on it (the config-port path) and the --dparam override test boots a
|
|
# second daemon on a different port.
|
|
config_port = _find_free_port()
|
|
with open(CONF_FILE, "w") as f:
|
|
f.write(
|
|
"# FastSync-native daemon config (Wave A grammar)\n"
|
|
"port = %d\n"
|
|
"\n"
|
|
"[files]\n"
|
|
"path = %s\n"
|
|
"\n"
|
|
"[readonly]\n"
|
|
"path = %s\n"
|
|
"read only = yes\n"
|
|
"\n"
|
|
"[locked]\n"
|
|
"path = %s\n"
|
|
"auth users = alice\n"
|
|
% (config_port, FILES_MODULE, READONLY_MODULE, AUTH_MODULE))
|
|
|
|
# A dedicated config for the real (double-fork) detach test: an unique path
|
|
# lets cleanup identify and kill the orphaned background daemon by cmdline.
|
|
global DETACH_PORT
|
|
DETACH_PORT = _find_free_port()
|
|
with open(DETACH_CONF, "w") as f:
|
|
f.write("port = %d\n\n[detach]\npath = %s\n" % (DETACH_PORT, DETACH_MODULE))
|
|
|
|
yield
|
|
_kill_by_cmdline_marker(DETACH_CONF)
|
|
shutil.rmtree(MODULE_ROOT, ignore_errors=True)
|
|
shutil.rmtree(SOURCE_DIR, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def daemon():
|
|
d = DaemonManager()
|
|
d.start(CONF_FILE)
|
|
yield d
|
|
d.stop()
|
|
|
|
|
|
def _push(dest, port):
|
|
result, _ = run_client(SOURCE_DIR, dest, port=port)
|
|
return result
|
|
|
|
|
|
class TestDaemonModuleSelection:
|
|
@pytest.mark.ci
|
|
def test_module_transfer(self, daemon):
|
|
"""A host::module/path destination lands inside the module root only."""
|
|
result = _push("127.0.0.1::files", daemon.port)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
received = get_dest_received_dir(FILES_MODULE, SOURCE_DIR)
|
|
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
|
assert not missing, f"missing: {missing[:5]}"
|
|
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
|
|
|
def test_module_subtree(self, daemon):
|
|
"""The /path part of host::module/path is relative inside the module."""
|
|
sub = os.path.join(FILES_MODULE, "subtree")
|
|
os.makedirs(sub, exist_ok=True)
|
|
result = _push("127.0.0.1::files/subtree", daemon.port)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
received = get_dest_received_dir(sub, SOURCE_DIR)
|
|
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
|
assert not missing, f"missing: {missing[:5]}"
|
|
assert not mismatches, f"mismatch: {mismatches[:5]}"
|
|
|
|
|
|
class TestDaemonRejection:
|
|
def _tree_files(self):
|
|
"""Snapshot every file path (module-relative) currently under the module
|
|
root tree, so confinement can be asserted by diff rather than by an
|
|
absolute 'empty' check (other tests legitimately populate modules)."""
|
|
files = set()
|
|
for root, _, names in os.walk(MODULE_ROOT):
|
|
for name in names:
|
|
full = os.path.join(root, name)
|
|
files.add(os.path.relpath(full, MODULE_ROOT))
|
|
return files
|
|
|
|
def test_read_only_module_blocked(self, daemon):
|
|
result = _push("127.0.0.1::readonly", daemon.port)
|
|
assert result.returncode != 0
|
|
file_count = sum(len(files) for _, _, files in os.walk(READONLY_MODULE))
|
|
assert file_count == 0, "read-only module must not receive any file"
|
|
|
|
def test_read_only_no_write_anywhere(self, daemon):
|
|
"""A refused read-only transfer must not add a single file anywhere under
|
|
the module root tree (negative confinement, not just the target)."""
|
|
before = self._tree_files()
|
|
result = _push("127.0.0.1::readonly", daemon.port)
|
|
assert result.returncode != 0
|
|
assert self._tree_files() == before, "read-only rejection wrote under the module root"
|
|
|
|
def test_unknown_module_rejected(self, daemon):
|
|
result = _push("127.0.0.1::no-such-module", daemon.port)
|
|
assert result.returncode != 0
|
|
|
|
def test_unknown_module_no_write_anywhere(self, daemon):
|
|
"""An unknown module must be refused cleanly before any file lands
|
|
anywhere beneath the module root tree."""
|
|
before = self._tree_files()
|
|
result = _push("127.0.0.1::no-such-module", daemon.port)
|
|
assert result.returncode != 0
|
|
assert self._tree_files() == before, "unknown-module rejection wrote under the module root"
|
|
|
|
def test_module_less_destination_rejected(self, daemon):
|
|
"""A daemon destination with no module name (host::/path) is refused at
|
|
parse time, before any connection payload is sent."""
|
|
result = _push("127.0.0.1::", daemon.port)
|
|
assert result.returncode != 0
|
|
result = _push("127.0.0.1::/sub", daemon.port)
|
|
assert result.returncode != 0
|
|
|
|
def test_dotdot_destination_rejected(self, daemon):
|
|
"""A '..' path expansion in the module-relative path is refused at parse
|
|
time so a client cannot escape the module root while it is still on the
|
|
client side of the wire."""
|
|
result = _push("127.0.0.1::files/../..", daemon.port)
|
|
assert result.returncode != 0
|
|
|
|
def test_auth_required_module_rejected(self, daemon):
|
|
result = _push("127.0.0.1::locked", daemon.port)
|
|
assert result.returncode != 0
|
|
file_count = sum(len(files) for _, _, files in os.walk(AUTH_MODULE))
|
|
assert file_count == 0
|
|
|
|
@pytest.mark.daemon_detach
|
|
def test_real_detach_path(self):
|
|
"""--daemon WITHOUT --no-detach double-forks a real background daemon;
|
|
a client can still transfer into the module root, and the orphaned
|
|
process is terminated cleanly (via SIGTERM after polling the port)."""
|
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_detach.log")
|
|
log = open(log_path, "w")
|
|
cmd = SERVER_CMD + ["--daemon", "--config", DETACH_CONF, "--allow-unauthenticated"]
|
|
proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL)
|
|
try:
|
|
_wait_for_port(DETACH_PORT, timeout=15)
|
|
result = _push("127.0.0.1::detach", DETACH_PORT)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
received = get_dest_received_dir(DETACH_MODULE, SOURCE_DIR)
|
|
_, missing = verify_transfer(SOURCE_DIR, received)
|
|
assert not missing, f"missing: {missing[:5]}"
|
|
finally:
|
|
_kill_by_cmdline_marker(DETACH_CONF)
|
|
|
|
def test_plaintext_requires_allow_unauthenticated(self):
|
|
"""Secure default: a daemon started WITHOUT --allow-unauthenticated must
|
|
refuse a plaintext client (same posture as the standalone server)."""
|
|
d = DaemonManager()
|
|
port = _find_free_port()
|
|
log_path = os.path.join(TEST_DATA_DIR, "fastsyncd_noauth.log")
|
|
log = open(log_path, "w")
|
|
cmd = SERVER_CMD + ["--daemon", "--config", CONF_FILE, "--no-detach",
|
|
"--dparam", f"port={port}"]
|
|
d._proc = subprocess.Popen(cmd, stdout=log, stderr=log, stdin=subprocess.DEVNULL,
|
|
start_new_session=True)
|
|
d._port = port
|
|
_wait_for_port(port, timeout=10)
|
|
try:
|
|
result = _push("127.0.0.1::files", port)
|
|
assert result.returncode != 0
|
|
finally:
|
|
d.stop()
|
|
|
|
def test_dparam_port_override(self):
|
|
"""--dparam port=N overrides the config's port and the daemon serves on N."""
|
|
override = _find_free_port()
|
|
d = DaemonManager()
|
|
d.start(CONF_FILE, port_override=override)
|
|
try:
|
|
result = _push("127.0.0.1::files", override)
|
|
assert result.returncode == 0, result.stderr or result.stdout
|
|
received = get_dest_received_dir(FILES_MODULE, SOURCE_DIR)
|
|
_, missing = verify_transfer(SOURCE_DIR, received)
|
|
assert not missing, f"missing: {missing[:5]}"
|
|
finally:
|
|
d.stop() |