From fad5fd3b908dbee2f91b85efc850b5f1b7d7db84 Mon Sep 17 00:00:00 2001 From: Francisco Javier Trujillo Mata Date: Sat, 31 Oct 2026 15:40:01 +0100 Subject: [PATCH] ps2client: Replace Python test helpers with plain C Run portable smoke tests with a native C harness, replace the Linux fake ps2link service and CRC helper with C programs, and remove the Python test dependency. Preserve the Bash namespace orchestrator for Linux-only integration tests. --- Makefile | 16 +- README.md | 6 +- tests/crc32.c | 18 +++ tests/fake_ps2link.c | 335 ++++++++++++++++++++++++++++++++++++++++++ tests/fake_ps2link.py | 187 ----------------------- tests/run-tests.sh | 6 +- tests/smoke.c | 142 ++++++++++++++++++ tests/smoke.py | 80 ---------- 8 files changed, 515 insertions(+), 275 deletions(-) create mode 100644 tests/crc32.c create mode 100644 tests/fake_ps2link.c delete mode 100755 tests/fake_ps2link.py create mode 100644 tests/smoke.c delete mode 100644 tests/smoke.py diff --git a/Makefile b/Makefile index 1cfa12b..24a8726 100644 --- a/Makefile +++ b/Makefile @@ -40,8 +40,8 @@ @mkdir -p $(DESTDIR)$(PREFIX)/bin cp bin/*client* $(DESTDIR)$(PREFIX)/bin - check: bin/ps2client - python3 tests/smoke.py bin/ps2client + check: bin/ps2client bin/smoke bin/fake_ps2link bin/crc32 + bin/smoke $(CURDIR)/bin/ps2client #################### ## CLIENT MODULES ## @@ -78,3 +78,15 @@ bin/ps2client: $(OFILES) src/ps2client.c @mkdir -p bin $(CC) $(CFLAGS) $(OFILES) src/ps2client.c -o bin/ps2client $(LIBS) + + bin/smoke: tests/smoke.c + @mkdir -p bin + $(CC) $(CFLAGS) $< -o $@ + + bin/fake_ps2link: tests/fake_ps2link.c + @mkdir -p bin + $(CC) $(CFLAGS) $< -o $@ -lpthread + + bin/crc32: tests/crc32.c + @mkdir -p bin + $(CC) $(CFLAGS) $< -o $@ diff --git a/README.md b/README.md index 94f4f7e..2f3c740 100644 --- a/README.md +++ b/README.md @@ -173,11 +173,11 @@ before they send the command, instead of waiting a fixed time. ### TESTS -`make check` runs portable smoke tests (including invalid hostnames and the -`console` command) on macOS and Linux without a PS2. +`make check` builds and runs plain-C portable smoke tests (including invalid +hostnames and the `console` command) on macOS and Linux without a PS2. `tests/run-tests.sh` runs the client against a fake ps2link -(`tests/fake_ps2link.py`) in unprivileged Linux network namespaces, so no ps2 +(`tests/fake_ps2link.c`) in unprivileged Linux network namespaces, so no ps2 is needed. ## Community diff --git a/tests/crc32.c b/tests/crc32.c new file mode 100644 index 0000000..5e4810a --- /dev/null +++ b/tests/crc32.c @@ -0,0 +1,18 @@ +/* Print IEEE/zlib CRC32 of a file for the fake-PS2Link regression suite. */ +#include +#include +int main(int argc, char **argv) +{ + FILE *input; + int ch, bit; + uint32_t crc = ~0u; + if (argc != 2 || !(input = fopen(argv[1], "rb"))) return 1; + while ((ch = fgetc(input)) != EOF) { /* Flawfinder: ignore */ + crc ^= (unsigned char)ch; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (crc & 1 ? 0xedb88320u : 0); + } + fclose(input); + printf("%08x\n", ~crc); + return 0; +} diff --git a/tests/fake_ps2link.c b/tests/fake_ps2link.c new file mode 100644 index 0000000..1e75b9d --- /dev/null +++ b/tests/fake_ps2link.c @@ -0,0 +1,335 @@ +/* Fake PS2Link server for the Linux network-namespace integration suite. + * Commands: script-based execee, dumpreg handshake, UDP console, host: I/O. + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define CMD_EXECEE 0xBABE0203u +#define CMD_DUMPREG 0xBABE020Au +#define REQ_OPEN 0xBABE0111u +#define REQ_CLOSE 0xBABE0121u +#define REQ_READ 0xBABE0131u +#define REQ_WRITE 0xBABE0141u + +struct fake { + int listener, commands, console, client; + struct sockaddr_in client_addr; + pthread_mutex_t lock; + int ignore_dumpreg; +}; +static struct fake fake = {.client = -1, .lock = PTHREAD_MUTEX_INITIALIZER}; + +static uint32_t get32(const unsigned char *p) +{ + uint32_t n; + memcpy(&n, p, 4); + return ntohl(n); +} +static void put32(unsigned char *p, uint32_t n) +{ + n = htonl(n); + memcpy(p, &n, 4); +} +static int recv_exact(int socket_fd, void *data, size_t length) +{ + size_t done = 0; + while (done < length) { + ssize_t received = recv(socket_fd, (char *)data + done, length - done, 0); + if (received <= 0) return -1; + done += (size_t)received; + } + return 0; +} +static int send_exact(int socket_fd, const void *data, size_t length) +{ + size_t done = 0; + while (done < length) { + ssize_t sent = send(socket_fd, (const char *)data + done, length - done, 0); + if (sent <= 0) return -1; + done += (size_t)sent; + } + return 0; +} +static int request_locked(uint32_t type, const unsigned char *payload, + size_t payload_len, const void *data, size_t length) +{ + unsigned char head[6]; + uint16_t packet_len = htons((uint16_t)(payload_len + 6)); + if (fake.client < 0) return -1; + put32(head, type); + memcpy(head + 4, &packet_len, 2); + if (send_exact(fake.client, head, sizeof(head)) || + (payload_len && send_exact(fake.client, payload, payload_len)) || + (length && send_exact(fake.client, data, length))) return -1; + return 0; +} +static int reply_locked(unsigned char *buffer, size_t length) +{ + return fake.client < 0 ? -1 : recv_exact(fake.client, buffer, length); +} +static int fake_open(const char *name, uint32_t flags) +{ + unsigned char payload[260] = {0}, reply[10]; + int result = -1; + put32(payload, flags); + snprintf((char *)payload + 4, 256, "%s", name); + pthread_mutex_lock(&fake.lock); + if (!request_locked(REQ_OPEN, payload, sizeof(payload), NULL, 0) && + !reply_locked(reply, sizeof(reply))) result = (int32_t)get32(reply + 6); + pthread_mutex_unlock(&fake.lock); + return result; +} +static void fake_close(int fd) +{ + unsigned char payload[4]; + put32(payload, (uint32_t)fd); + pthread_mutex_lock(&fake.lock); + if (!request_locked(REQ_CLOSE, payload, sizeof(payload), NULL, 0)) { + unsigned char reply[10]; + reply_locked(reply, sizeof(reply)); + } + pthread_mutex_unlock(&fake.lock); +} +static int fake_read(int fd, unsigned char *buffer, int max_size) +{ + unsigned char payload[8], reply[14]; + int count = -1; + put32(payload, (uint32_t)fd); + put32(payload + 4, (uint32_t)max_size); + pthread_mutex_lock(&fake.lock); + if (!request_locked(REQ_READ, payload, sizeof(payload), NULL, 0) && + !reply_locked(reply, sizeof(reply))) { + count = (int32_t)get32(reply + 10); + if (count < 0 || count > max_size || + (count && reply_locked(buffer, (size_t)count))) count = -1; + } + pthread_mutex_unlock(&fake.lock); + return count; +} +static int fake_write(int fd, const unsigned char *data, int length) +{ + unsigned char payload[8], reply[10]; + int result = -1; + put32(payload, (uint32_t)fd); + put32(payload + 4, (uint32_t)length); + pthread_mutex_lock(&fake.lock); + if (!request_locked(REQ_WRITE, payload, sizeof(payload), data, length) && + !reply_locked(reply, sizeof(reply))) result = (int32_t)get32(reply + 6); + pthread_mutex_unlock(&fake.lock); + return result; +} +static unsigned char *load_file(const char *path, size_t *length) +{ + unsigned char *data = NULL; + unsigned char chunk[128 * 1024]; + size_t allocated = 0; + int fd = fake_open(path, 1), n; + *length = 0; + if (fd < 0) return NULL; + while ((n = fake_read(fd, chunk, sizeof(chunk))) > 0) { + if (*length + (size_t)n + 1 > allocated) { + size_t target = (*length + (size_t)n + 1) * 2; + unsigned char *grown = realloc(data, target); + if (!grown) { free(data); data = NULL; break; } + data = grown; allocated = target; + } + memcpy(data + *length, chunk, (size_t)n); + *length += (size_t)n; + } + fake_close(fd); + if (n < 0 || !data) { free(data); return NULL; } + data[*length] = '\0'; + return data; +} +static void console(const void *data, size_t len) +{ + struct sockaddr_in peer; + pthread_mutex_lock(&fake.lock); + peer = fake.client_addr; + pthread_mutex_unlock(&fake.lock); + peer.sin_port = htons(0x4712); + sendto(fake.console, data, len, 0, (struct sockaddr *)&peer, sizeof(peer)); +} +static void line_out(const char *line) +{ + char text[2048]; + int n = snprintf(text, sizeof(text), "%s\n", line); + if (n > 0) console(text, (size_t)n < sizeof(text) ? (size_t)n : sizeof(text) - 1); +} +static uint32_t crc32_bytes(const unsigned char *data, size_t length) +{ + uint32_t crc = ~0u; + size_t i; + int bit; + for (i = 0; i < length; i++) { + crc ^= data[i]; + for (bit = 0; bit < 8; bit++) + crc = (crc >> 1) ^ (crc & 1 ? 0xedb88320u : 0); + } + return ~crc; +} +static void sleep_seconds(double seconds) +{ + struct timespec time; + if (seconds < 0) return; + time.tv_sec = (time_t)seconds; + time.tv_nsec = (long)((seconds - time.tv_sec) * 1e9); + nanosleep(&time, NULL); +} +static void *run_script(void *context) +{ + char *script = context, *line, *next; + for (line = script; line && *line; line = next) { + char *arg; + next = strchr(line, '\n'); + if (next) *next++ = '\0'; + arg = strchr(line, ' '); + if (arg) *arg++ = '\0'; + else arg = line + strcspn(line, "\r\n"); + if (!strcmp(line, "print")) line_out(arg); + else if (!strcmp(line, "raw")) { + int count = 0; + char ch = 0; + if (sscanf(arg, "%d %c", &count, &ch) == 2 && count > 0 && + count <= 1600) { + char bytes[1600]; + memset(bytes, ch, (size_t)count); + console(bytes, (size_t)count); + } + } else if (!strcmp(line, "sleep")) sleep_seconds(strtod(arg, NULL)); + else if (!strcmp(line, "close")) { + pthread_mutex_lock(&fake.lock); + if (fake.client >= 0) { + shutdown(fake.client, SHUT_RDWR); + close(fake.client); + fake.client = -1; + } + pthread_mutex_unlock(&fake.lock); + break; + } else if (!strcmp(line, "readback")) { + size_t length = 0; + unsigned char *data = load_file(arg, &length); + if (data) { + char report[128]; + snprintf(report, sizeof(report), "readback %zu %08x", + length, crc32_bytes(data, length)); + line_out(report); + free(data); + } + } + /* A script may end at the last line without a trailing newline. */ + } + free(script); + return NULL; +} +static void command_packet(unsigned char *packet, size_t length) +{ + uint32_t type; + if (length < 10) return; + type = get32(packet); + if (type == CMD_DUMPREG && length >= 266) { + int fd; + if (fake.ignore_dumpreg > 0) { fake.ignore_dumpreg--; return; } + fd = fake_open((char *)packet + 10, 0x0202); + if (fd >= 0) { + unsigned char zeros[8] = {0}; + fake_write(fd, zeros, sizeof(zeros)); + fake_close(fd); + } + } else if (type == CMD_EXECEE && length >= 266) { + char args[256], *first, *path; + char output[512] = "fake: args "; + size_t size = 0; + unsigned char *script; + pthread_t worker; + uint32_t count; + uint32_t i; + size_t offset; + count = get32(packet + 6); + memcpy(args, packet + 10, sizeof(args)); + args[255] = '\0'; + first = args; + path = strncmp(first, "host:", 5) == 0 ? first + 5 : first; + script = load_file(path, &size); + if (!script) { line_out("fake: could not load script"); return; } + offset = strnlen(first, sizeof(args)) + 1; + for (i = 1; i < count && offset < 256; i++) { + size_t used = strnlen(output, sizeof(output)); + size_t current = strnlen(args + offset, sizeof(args) - offset); + snprintf(output + used, sizeof(output) - used, "%s%s", + i > 1 ? " " : "", args + offset); + offset += current + 1; + } + line_out(output); + if (!pthread_create(&worker, NULL, run_script, script)) + pthread_detach(worker); + else free(script); + } +} +static int bind_port(int type, const char *ip, uint16_t port) +{ + int fd = socket(AF_INET, type, 0), reuse = 1; + struct sockaddr_in addr = {0}; + if (fd < 0) return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1 || + bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + close(fd); return -1; + } + return fd; +} +int main(int argc, char **argv) +{ + const char *ip = NULL; + int i; + fake.console = socket(AF_INET, SOCK_DGRAM, 0); + for (i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--bind") && i + 1 < argc) ip = argv[++i]; + else if (!strcmp(argv[i], "--ignore-dumpreg") && i + 1 < argc) + fake.ignore_dumpreg = atoi(argv[++i]); + else { fprintf(stderr, "Unknown option: %s\n", argv[i]); return 2; } + } + if (!ip) { fprintf(stderr, "Usage: fake_ps2link --bind IPV4 [--ignore-dumpreg N]\n"); return 2; } + fake.listener = bind_port(SOCK_STREAM, ip, 0x4711); + fake.commands = bind_port(SOCK_DGRAM, ip, 0x4712); + if (fake.listener < 0 || fake.commands < 0 || listen(fake.listener, 5) < 0) { + perror("fake_ps2link bind/listen"); return 1; + } + for (;;) { + fd_set set; + int ready, maxfd = fake.listener > fake.commands ? fake.listener : fake.commands; + FD_ZERO(&set); FD_SET(fake.listener, &set); FD_SET(fake.commands, &set); + ready = select(maxfd + 1, &set, NULL, NULL, NULL); + if (ready <= 0) continue; + if (FD_ISSET(fake.listener, &set)) { + struct sockaddr_in peer; + socklen_t len = sizeof(peer); + int client = accept(fake.listener, (struct sockaddr *)&peer, &len); + if (client < 0) continue; + pthread_mutex_lock(&fake.lock); + if (fake.client >= 0) close(fake.client); + fake.client = client; + fake.client_addr = peer; + pthread_mutex_unlock(&fake.lock); + { int one = 1; + setsockopt(client, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); } + } else if (FD_ISSET(fake.commands, &set)) { + unsigned char packet[2048]; + ssize_t n = recv(fake.commands, packet, sizeof(packet), 0); + if (n > 0 && fake.client >= 0) command_packet(packet, (size_t)n); + } + } +} diff --git a/tests/fake_ps2link.py b/tests/fake_ps2link.py deleted file mode 100755 index 803f804..0000000 --- a/tests/fake_ps2link.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""A fake ps2link for testing ps2client without a PS2.""" - -# It speaks the ps2link protocol: it accepts the request connection on TCP -# 0x4711, takes commands on UDP 0x4712 and sends console output to UDP 0x4712 -# of the client. Run it on another address than the client (see run-tests.sh), -# since both use UDP port 0x4712. -# -# execee loads its file through host: requests like ps2link does, then runs it -# as a script, one command per line: -# -# print console output, a newline is added -# raw one console packet of n bytes of , no newline -# sleep wait -# close close the request connection (ps2link went away) -# readback read a host: file and print its size and checksum -# -# dumpreg writes 8 bytes to the given host: file, like ps2link's INTC dump. - -import argparse -import select -import socket -import struct -import threading -import time -import zlib - -PORT_REQUEST = 0x4711 -PORT_COMMAND = 0x4712 -PORT_CONSOLE = 0x4712 - -CMD_EXECEE = 0xBABE0203 -CMD_DUMPREG = 0xBABE020A -REQ_OPEN, REQ_CLOSE, REQ_READ, REQ_WRITE = 0xBABE0111, 0xBABE0121, 0xBABE0131, 0xBABE0141 - -PS2_O_RDONLY, PS2_O_WRONLY, PS2_O_CREAT = 0x0001, 0x0002, 0x0200 - - -class Fake: - """The state of the fake ps2link and the host: requests it makes.""" - - def __init__(self, ignore_dumpreg): - """Start without a request connection.""" - self.conn = None - self.client_ip = None - self.lock = threading.Lock() - self.ignore_dumpreg = ignore_dumpreg - self.console = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - def recv_all(self, n): - data = b"" - while len(data) < n: - chunk = self.conn.recv(n - len(data)) - if not chunk: - raise ConnectionError("client closed") - data += chunk - return data - - def request(self, number, payload, data=b""): - # The length covers the header, not the data of a write. - self.conn.sendall(struct.pack(">IH", number, 6 + len(payload)) + payload + data) - - def response(self, size): - return self.recv_all(size) - - def open(self, path, flags): - with self.lock: - self.request(REQ_OPEN, struct.pack(">i256s", flags, path.encode())) - _, _, fd = struct.unpack(">IHi", self.response(10)) - return fd - - def close(self, fd): - with self.lock: - self.request(REQ_CLOSE, struct.pack(">i", fd)) - self.response(10) - - def read(self, fd, size): - with self.lock: - self.request(REQ_READ, struct.pack(">ii", fd, size)) - _, _, _, n = struct.unpack(">IHii", self.response(14)) - return self.recv_all(n) if n > 0 else b"" - - def write(self, fd, data): - with self.lock: - self.request(REQ_WRITE, struct.pack(">ii", fd, len(data)), data) - self.response(10) - - def load(self, path): - fd = self.open(path, PS2_O_RDONLY) - if fd < 0: - return None - data = b"" - while True: - # Big reads, like the IOP does for ELF loading. - chunk = self.read(fd, 128 * 1024) - if not chunk: - break - data += chunk - self.close(fd) - return data - - def say(self, data): - self.console.sendto(data, (self.client_ip, PORT_CONSOLE)) - - def run_script(self, text): - for line in text.splitlines(): - cmd, _, arg = line.partition(" ") - if cmd == "print": - self.say(arg.encode() + b"\n") - elif cmd == "raw": - n, ch = arg.split() - self.say(ch.encode() * int(n)) - elif cmd == "sleep": - time.sleep(float(arg)) - elif cmd == "close": - self.conn.shutdown(socket.SHUT_RDWR) - self.conn.close() - return - elif cmd == "readback": - data = self.load(arg) - self.say(b"readback %d %08x\n" % (len(data), zlib.crc32(data))) - - def command(self, packet): - number, _ = struct.unpack(">IH", packet[:6]) - if number == CMD_DUMPREG: - if self.ignore_dumpreg > 0: - self.ignore_dumpreg -= 1 - return - _, path = struct.unpack(">i256s", packet[6:6 + 260]) - fd = self.open(path.split(b"\0")[0].decode(), PS2_O_WRONLY | PS2_O_CREAT) - if fd >= 0: - self.write(fd, b"\0" * 8) - self.close(fd) - elif number == CMD_EXECEE: - argc, argv = struct.unpack(">i256s", packet[6:6 + 260]) - args = argv.split(b"\0")[:argc] - path = args[0].decode().removeprefix("host:") - script = self.load(path) - if script is None: - self.say(b"fake: could not load %s\n" % path.encode()) - return - self.say(b"fake: args " + b" ".join(args[1:]) + b"\n") - threading.Thread(target=self.run_script, args=(script.decode(),), daemon=True).start() - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--bind", required=True, help="the address to serve on") - ap.add_argument("--ignore-dumpreg", type=int, default=0, help="ignore the first N dumpreg commands (not ready yet)") - opts = ap.parse_args() - fake = Fake(opts.ignore_dumpreg) - - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind((opts.bind, PORT_REQUEST)) - server.listen(5) - commands = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - commands.bind((opts.bind, PORT_COMMAND)) - - def accept(): - conn, addr = server.accept() - # Like ps2link: a new client replaces the old one. - if fake.conn: - fake.conn.close() - conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - fake.conn, fake.client_ip = conn, addr[0] - - while True: - # A client connects before it sends a command, so take pending - # connections first: the command is for the newest one. - ready, _, _ = select.select([server, commands], [], []) - if server in ready: - accept() - continue - packet, _ = commands.recvfrom(2048) - # Without a request connection the host: open fails and the command - # does nothing, as on ps2link. - if fake.conn is None: - continue - try: - fake.command(packet) - except (OSError, ConnectionError): - pass - - -if __name__ == "__main__": - main() diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 87797dc..cb85925 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Test ps2client against tests/fake_ps2link.py (Linux only). +# Test ps2client against tests/fake_ps2link.c (Linux only). # # ps2client and ps2link both use UDP port 0x4712, so the fake runs in its own # network namespace (10.0.0.2), linked by a veth pair to the client's @@ -32,7 +32,7 @@ start_fake() { unshare -n sh -c ' while ! ip link show veth1 >/dev/null 2>&1; do sleep 0.02; done ip link set lo up; ip addr add 10.0.0.2/24 dev veth1; ip link set veth1 up - exec python3 -I "$0" --bind 10.0.0.2 "$@"' "$HERE/fake_ps2link.py" "$@" & + exec "$0" --bind 10.0.0.2 "$@"' "$HERE/../bin/fake_ps2link" "$@" & FAKE_PID=$! # Move the link only once the child has its own namespace. while [ "$(readlink /proc/$FAKE_PID/ns/net)" = "$(readlink /proc/self/ns/net)" ]; do sleep 0.01; done @@ -141,7 +141,7 @@ expect "idle timeout waits" test "$MS" -ge 990 head -c 300000 /dev/urandom > blob.bin printf 'readback blob.bin\nprint @@END@@ status=0\n' > readback.elf check "big host: read" 0 "${H[@]}" -t 10 -e @@END@@ execee host:readback.elf -expect "readback size and crc" grep -q "^readback 300000 $(python3 -c 'import sys,zlib;print("%08x"%zlib.crc32(open(sys.argv[1],"rb").read()))' blob.bin)$" out.txt +expect "readback size and crc" grep -q "^readback 300000 $("$HERE/../bin/crc32" blob.bin)$" out.txt # --- second client "$CLIENT" "${H[@]}" -t 5 listen > /dev/null 2>&1 & diff --git a/tests/smoke.c b/tests/smoke.c new file mode 100644 index 0000000..4a4b630 --- /dev/null +++ b/tests/smoke.c @@ -0,0 +1,142 @@ +/* Portable POSIX ps2client smoke tests: no actual PS2 or namespaces needed. */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *client; +static int failures; +static void check(int ok, const char *name) +{ + printf("%s %s\n", ok ? "PASS" : "FAIL", name); + failures += !ok; +} +static void pause_ms(long ms) +{ + struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +} +static pid_t spawn(char *const arguments[], int output_fd, const char *hostname) +{ + pid_t pid = fork(); + if (pid == 0) { + if (output_fd >= 0) { + dup2(output_fd, STDOUT_FILENO); + dup2(output_fd, STDERR_FILENO); + } + if (hostname) setenv("PS2HOSTNAME", hostname, 1); + execv(client, arguments); + _exit(127); + } + return pid; +} +static int status_code(pid_t pid) +{ + int status; + if (pid < 0 || waitpid(pid, &status, 0) < 0 || !WIFEXITED(status)) + return -1; + return WEXITSTATUS(status); +} +static int run(char *const args[], const char *hostname, char *text, + size_t capacity) +{ + char filename[] = "/tmp/ps2client-smoke-XXXXXX"; + int fd = mkstemp(filename), rc; + ssize_t n; + pid_t pid; + if (fd < 0) return -1; + unlink(filename); + pid = spawn(args, fd, hostname); + rc = status_code(pid); + lseek(fd, 0, SEEK_SET); + n = read(fd, text, capacity - 1); /* Flawfinder: ignore */ + text[n < 0 ? 0 : (size_t)n] = '\0'; + close(fd); + return rc; +} +static void test_console(void) +{ + struct sockaddr_in addr = {0}; + char filename[] = "/tmp/ps2console-XXXXXX", text[4096]; + char *const args[] = {(char *)client, "-h", "1.1.l.1", "-t", "3", + "-e", "smoke-console-line", "console", NULL}; + int fd = mkstemp(filename), sock = socket(AF_INET, SOCK_DGRAM, 0), i, rc; + pid_t pid; + ssize_t n; + if (fd < 0 || sock < 0) { check(0, "console setup"); return; } + unlink(filename); + pid = spawn(args, fd, NULL); + addr.sin_family = AF_INET; + addr.sin_port = htons(0x4712); + addr.sin_addr.s_addr = htonl(0x7f000001u); + pause_ms(100); + for (i = 0; i < 15; i++) { + sendto(sock, "smoke-console-line\n", 19, 0, + (struct sockaddr *)&addr, sizeof(addr)); + pause_ms(45); + } + close(sock); + rc = status_code(pid); + lseek(fd, 0, SEEK_SET); + n = read(fd, text, sizeof(text) - 1); /* Flawfinder: ignore */ + text[n < 0 ? 0 : (size_t)n] = '\0'; + close(fd); + check(rc == 0 && strstr(text, "smoke-console-line"), + "UDP-only console logs with invalid PS2 hostname"); +} +static void test_reset(void) +{ + struct sockaddr_in addr = {0}; + char result[1024]; + unsigned char packet[32]; + char *const args[] = {(char *)client, "-h", "127.0.0.1", "reset", NULL}; + int fd = socket(AF_INET, SOCK_DGRAM, 0), rc; + struct timeval tv = {2, 0}; + ssize_t n; + if (fd < 0) { check(0, "reset socket"); return; } + addr.sin_family = AF_INET; + addr.sin_port = htons(0x4712); + addr.sin_addr.s_addr = htonl(0x7f000001u); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + check(0, "reset bind"); close(fd); return; + } + rc = run(args, NULL, result, sizeof(result)); + n = recv(fd, packet, sizeof(packet), 0); + close(fd); + check(rc == 0 && n == 6 && packet[0] == 0xba && packet[1] == 0xbe && + packet[2] == 0x02 && packet[3] == 0x01 && + packet[4] == 0 && packet[5] == 6, + "reset uses command UDP without TCP"); +} +int main(int argc, char **argv) +{ + char text[4096]; + int rc; + char *invalid_opt[] = {NULL, "-h", "1.1.l.1", "ping", NULL}; + char *invalid_env[] = {NULL, "ping", NULL}; + char *listen_args[] = {NULL, "-h", "1.1.l.1", "-t", "1", "listen", NULL}; + if (argc != 2) { fprintf(stderr, "Usage: %s /path/to/ps2client\n", argv[0]); return 2; } + client = argv[1]; + invalid_opt[0] = (char *)client; + invalid_env[0] = (char *)client; + listen_args[0] = (char *)client; + rc = run(invalid_opt, NULL, text, sizeof(text)); + check(rc == 69 && strstr(text, "1.1.l.1"), "invalid CLI hostname"); + rc = run(invalid_env, "1.1.l.1", text, sizeof(text)); + check(rc == 69 && strstr(text, "1.1.l.1"), "invalid environment hostname"); + rc = run(listen_args, NULL, text, sizeof(text)); + check(rc == 69, "legacy listen requires host: service"); + test_console(); + test_reset(); + return failures ? 1 : 0; +} diff --git a/tests/smoke.py b/tests/smoke.py deleted file mode 100644 index 5475146..0000000 --- a/tests/smoke.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Portable ps2client smoke tests; no physical PS2 or Linux namespaces needed.""" - -import os -import socket -import struct -import subprocess # nosec B404 -import sys -import time -import unittest - - -BAD_HOST = "1.1.l.1" -CONSOLE_PORT = 0x4712 -RESET_PACKET = 0xBABE0201 - - -class SmokeTests(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.client = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "bin/ps2client") - - def run_client(self, *args, env=None): - # The test runner supplies a local executable and fixed argument lists; - # no shell is involved. - return subprocess.run( # nosec B603 - [self.client, *args], capture_output=True, text=True, timeout=8, env=env - ) - - def test_invalid_hostname_option(self): - result = self.run_client("-h", BAD_HOST, "ping") - self.assertEqual(result.returncode, 69, result.stderr) - self.assertIn(BAD_HOST, result.stderr) - - def test_invalid_hostname_environment(self): - env = os.environ.copy() - env["PS2HOSTNAME"] = BAD_HOST - result = self.run_client("ping", env=env) - self.assertEqual(result.returncode, 69, result.stderr) - self.assertIn(BAD_HOST, result.stderr) - - def test_console_without_ps2_or_resolvable_host(self): - # console must bind the UDP console port without trying to resolve - # the PS2 address or connect to the request/command channels. - # The executable and command arguments are controlled by this test. - proc = subprocess.Popen( # nosec B603 - [self.client, "-h", BAD_HOST, "-t", "1", "console"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - ) - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender: - time.sleep(0.2) - for _ in range(5): - sender.sendto(b"smoke-console-line\n", ("127.0.0.1", CONSOLE_PORT)) - time.sleep(0.05) - stdout, stderr = proc.communicate(timeout=5) - self.assertEqual(proc.returncode, 0, stderr) - self.assertIn("smoke-console-line", stdout) - finally: - if proc.poll() is None: - proc.kill() - proc.communicate(timeout=5) - - def test_legacy_listen_still_requires_host_service(self): - result = self.run_client("-h", BAD_HOST, "-t", "1", "listen") - self.assertEqual(result.returncode, 69, result.stderr) - - def test_reset_uses_udp_without_tcp_server(self): - # Capture the reset packet on loopback: the test must not reset a PS2. - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver: - receiver.bind(("127.0.0.1", CONSOLE_PORT)) - receiver.settimeout(3) - result = self.run_client("-h", "127.0.0.1", "reset") - self.assertEqual(result.returncode, 0, result.stderr) - packet, _ = receiver.recvfrom(256) - self.assertEqual(struct.unpack(">IH", packet), (RESET_PACKET, 6)) - - -if __name__ == "__main__": - unittest.main(argv=[sys.argv[0]], verbosity=2)