feat(d5-daemon-motd): daemon MOTD display + --no-motd
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_MOTD_H
|
||||
#define TEST_MOTD_H
|
||||
|
||||
void test_motd(void);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user