feat(d5-daemon-motd): daemon MOTD display + --no-motd

This commit is contained in:
2026-09-10 13:52:24 +02:00
parent 8330f275a6
commit cf5f730940
14 changed files with 490 additions and 4 deletions
+93
View File
@@ -539,6 +539,99 @@ class TestDaemonAuthentication:
assert _pw_hash(WRONG_PASS) not in log
class TestDaemonMotd:
"""Wave C MOTD: a daemon configured with a global `motd file` sends it to a
host::module/path client right after the config/auth handshake; the client
shows it on stdout unless --no-motd suppresses the display. The MOTD is
escaped at display time so a hostile motd cannot inject terminal escapes.
Each test boots its own motd-configured daemon (the shared `daemon` fixture
config has no `motd file`). The MOTD is only sent on the daemon listener
path; these all exercise `host::module` connections.
"""
MOTD_MODULE = os.path.join(MODULE_ROOT, "motd_module")
MOTD_CONF = os.path.join(TEST_DATA_DIR, "fastsyncd_motd.conf")
def _start(self, motd_path):
port = _find_free_port()
motd_line = "motd file = %s\n" % motd_path if motd_path else ""
os.makedirs(self.MOTD_MODULE, exist_ok=True)
with open(self.MOTD_CONF, "w") as f:
f.write("port = %d\n%s\n[files]\npath = %s\n" % (port, motd_line, self.MOTD_MODULE))
d = DaemonManager()
d.start(self.MOTD_CONF, port_override=port)
return d, port
def _push(self, port, extra_args=None):
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files", port=port,
extra_args=extra_args)
return result
@pytest.mark.ci
def test_motd_displayed(self):
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_banner.txt")
banner = "Welcome to the FastSync test daemon\nSecond line here.\n"
with open(motd_path, "w") as f:
f.write(banner)
d, port = self._start(motd_path)
try:
result = self._push(port)
assert result.returncode == 0, result.stderr or result.stdout
assert "Welcome to the FastSync test daemon" in (result.stdout or "")
assert "Second line here." in (result.stdout or "")
finally:
d.stop()
def test_motd_no_motd_suppresses_display(self):
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_banner2.txt")
banner = "This banner must never be shown.\n"
with open(motd_path, "w") as f:
f.write(banner)
d, port = self._start(motd_path)
try:
result = self._push(port, extra_args=["--no-motd"])
assert result.returncode == 0, result.stderr or result.stdout
assert banner.strip() not in (result.stdout or "")
finally:
d.stop()
def test_motd_absent_motd_file_no_error(self):
d, port = self._start(os.path.join(TEST_DATA_DIR, "no-such-motd-file.txt"))
try:
result = self._push(port)
assert result.returncode == 0, result.stderr or result.stdout
assert "no-such-motd" not in (result.stdout or "")
finally:
d.stop()
def test_motd_no_config_key_sends_no_banner(self):
d, port = self._start(None)
try:
result = self._push(port)
assert result.returncode == 0, result.stderr or result.stdout
finally:
d.stop()
def test_motd_control_bytes_are_escaped(self):
"""A hostile motd (ANSI escape sequences) is displayed with every
control byte escaped octal-style, so no terminal escape reaches the
controlling terminal. The transfer still succeeds (the motd is only
display text, never a wire/transfer hazard)."""
motd_path = os.path.join(TEST_DATA_DIR, "fastsyncd_motd_hostile.txt")
with open(motd_path, "w") as f:
f.write("hello\033[31mred\033[0m\n")
d, port = self._start(motd_path)
try:
result = self._push(port)
assert result.returncode == 0, result.stderr or result.stdout
assert "\x1b" not in (result.stdout or ""), "raw ESC byte leaked to stdout"
assert "\\#033[31m" in (result.stdout or ""), result.stdout
assert "\\#033[0m" in (result.stdout or ""), result.stdout
finally:
d.stop()
def _generate_tls_certs(cert_dir):
"""Generate a self-signed CA, server cert (with 127.0.0.1 SAN) and a client
cert signed by that CA, for the TLS+auth composition test."""
+2
View File
@@ -16,6 +16,7 @@
#include "test_glob.h"
#include "test_log.h"
#include "test_metadata.h"
#include "test_motd.h"
#include "test_multiprocessing.h"
#include "test_property.h"
#include "test_protocol.h"
@@ -73,6 +74,7 @@ int main() {
RUN_TEST(test_client_cli);
RUN_TEST(test_server);
RUN_TEST(test_daemon_conf);
RUN_TEST(test_motd);
RUN_TEST(test_server_cli);
RUN_TEST(test_fuzz_smoke);
RUN_TEST(test_xattr);
+24
View File
@@ -2772,6 +2772,29 @@ static void test_parse_args_remote_option_no_short_M() {
config_delete(cfg);
}
/* --no-motd is a real rsync option (client-side daemon MOTD display
* suppression), not a negation of a --motd flag: it sets config->no_motd. */
static void test_parse_args_no_motd() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
EXPECT_FALSE(cfg->no_motd);
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", "--no-motd"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->no_motd);
config_delete(cfg);
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
EXPECT_FALSE(cfg->no_motd);
char* argv2[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->no_motd);
config_delete(cfg);
}
/* --password-file stores its path on the config (the file is read later, once
* the destination form is known). */
static void test_parse_args_password_file() {
@@ -2934,5 +2957,6 @@ void test_client_cli() {
test_parse_args_remote_option_missing_value();
test_parse_args_remote_option_rejects_bad_values();
test_parse_args_remote_option_no_short_M();
test_parse_args_no_motd();
test_parse_args_password_file();
}
+149
View File
@@ -0,0 +1,149 @@
#include "test_motd.h"
#include "motd.h"
#include "protocol.h"
#include "test_utils.h"
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* Write `body` (len bytes) to a fresh temp file; returns its heap path. */
static int write_file(const char* body, size_t len, char** out_path) {
char tmpl[] = "/tmp/fastsync_motd_XXXXXX";
int fd = mkstemp(tmpl);
if (fd < 0)
return -1;
if (write(fd, body, len) != (ssize_t)len) {
close(fd);
unlink(tmpl);
return -1;
}
close(fd);
*out_path = strdup(tmpl);
return *out_path ? 0 : -1;
}
static void test_motd_read_present() {
char* path;
char body[] = "Welcome to FastSync\nBe excellent to each other.\n";
EXPECT_EQ_INT(write_file(body, strlen(body), &path), 0);
char* motd = motd_read_file(path);
unlink(path);
free(path);
EXPECT_NOT_NULL(motd);
EXPECT_EQ_STR(motd, body);
free(motd);
}
static void test_motd_read_absent() {
EXPECT_NULL(motd_read_file("/nonexistent/fastsync_motd_zzz"));
EXPECT_NULL(motd_read_file(""));
EXPECT_NULL(motd_read_file(NULL));
}
static void test_motd_read_unreadable() {
/* Reading a directory through fopen succeeds for the open but fread fails
* with EISDIR, which is a reliable "unreadable" probe even for root. */
const char* dir = "/tmp";
EXPECT_NULL(motd_read_file(dir));
}
static void test_motd_read_large_truncated() {
size_t total = MOTD_MAX_BYTES + 100;
char* body = malloc(total);
EXPECT_NOT_NULL(body);
memset(body, 'x', total);
body[0] = 'h';
char* path;
EXPECT_EQ_INT(write_file(body, total, &path), 0);
char* motd = motd_read_file(path);
unlink(path);
free(path);
EXPECT_NOT_NULL(motd);
EXPECT_EQ_INT((int)strlen(motd), MOTD_MAX_BYTES);
EXPECT_EQ_INT(motd[0], 'h');
EXPECT_EQ_INT(motd[MOTD_MAX_BYTES - 1], 'x');
EXPECT_EQ_STR(motd + MOTD_MAX_BYTES, "");
free(motd);
free(body);
}
static void test_motd_render_escaping() {
/* Newlines/tabs survive; control bytes (ESC included) become \NNN octal. */
char* rendered = motd_render("line1\n\tansi\033[31m", false);
EXPECT_NOT_NULL(rendered);
EXPECT_EQ_STR(rendered, "line1\n\tansi\\#033[31m");
free(rendered);
/* High-bit bytes are escaped without --8-bit-output. */
rendered = motd_render("\xC3\xA9", false);
EXPECT_NOT_NULL(rendered);
EXPECT_EQ_STR(rendered, "\\#303\\#251");
free(rendered);
/* --8-bit-output keeps bytes >= 0x80 verbatim. */
rendered = motd_render("\xC3\xA9", true);
EXPECT_NOT_NULL(rendered);
EXPECT_EQ_STR(rendered, "\xC3\xA9");
free(rendered);
EXPECT_NULL(motd_render(NULL, false));
}
static void test_motd_frame_roundtrip() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
const char* motd = "Greetings from the module server.\nEnjoy your stay.\n";
EXPECT_TRUE(motd_send(0, motd));
char* received = motd_receive(0);
EXPECT_NOT_NULL(received);
EXPECT_EQ_STR(received, motd);
free(received);
/* An unset MOTD is an empty (but present) frame, not an error. */
EXPECT_TRUE(motd_send(0, NULL));
received = motd_receive(0);
EXPECT_NOT_NULL(received);
EXPECT_EQ_STR(received, "");
free(received);
close(p[0]);
close(p[1]);
}
static void test_motd_receive_over_bound_rejected() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
size_t size = MOTD_MAX_BYTES + 100;
char* big = malloc(size);
EXPECT_NOT_NULL(big);
memset(big, 'a', size);
big[size - 1] = '\0';
/* A (hostile/oversized) peer frame within MAX_STRING_SIZE but above the MOTD
* bound is consumed and discarded: motd_receive returns NULL and the stream
* stays framed for the next message. */
EXPECT_TRUE(send_str(0, big));
EXPECT_NULL(motd_receive(0));
EXPECT_TRUE(send_str(0, "after"));
char* next = receive_str(0);
EXPECT_NOT_NULL(next);
EXPECT_EQ_STR(next, "after");
free(next);
free(big);
close(p[0]);
close(p[1]);
}
void test_motd() {
test_motd_read_present();
test_motd_read_absent();
test_motd_read_unreadable();
test_motd_read_large_truncated();
test_motd_render_escaping();
test_motd_frame_roundtrip();
test_motd_receive_over_bound_rejected();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_MOTD_H
#define TEST_MOTD_H
void test_motd(void);
#endif