diff --git a/tests/fuzz/fuzz_manifest.c b/tests/fuzz/fuzz_manifest.c new file mode 100644 index 0000000..57a9f3e --- /dev/null +++ b/tests/fuzz/fuzz_manifest.c @@ -0,0 +1,126 @@ +/* + * Fuzz the delete-manifest parser: receive_manifest_entries(int fd). + * + * The parser reads three length-delimited sections (keeps, protected prefixes, + * missing-args paths) from the connection. Feeding raw bytes alone exercises + * the "reject the first malformed count/string" fast paths, but because each + * section is self-delimiting a single bad value hides every later section. + * + * To reach the protected-prefix and missing-args parsers (the paths that drive + * actual destination deletion) we build one canonical, fully-valid manifest + * with hand-written wire framing and then feed the receiver several shapes: + * + * 1. raw : the raw fuzz bytes as the whole manifest. + * 2. keeps : the valid keep count only + the fuzz bytes, so the fuzzer + * drives the keep count and entries directly. + * 3. prot : the valid keeps section + the fuzz bytes, so the fuzzer drives + * the protected count and prefixes. + * 4. missing: the valid keeps+protected sections + the fuzz bytes, so the + * fuzzer drives the trailing missing-args section, including the + * aggregate MAX_MANIFEST_BYTES budget. + * + * The wire encoding matches receive_int (native int) and receive_wire_str + * (native size_t length prefix + body); no charset conversion is configured in + * the fuzz process, so receive_wire_str is receive_str. + */ +#include "file_receive.h" +#include "protocol.h" +#include +#include +#include +#include +#include +#include +#include + +static unsigned char g_manifest[512]; +static size_t g_len_after_count; /* offset of the first keep entry */ +static size_t g_len_after_keeps; /* offset of the protected count */ +static size_t g_len_after_protected; /* offset of the missing count */ +static int g_manifest_ready; + +static void append_int32(unsigned char* buf, size_t* off, int32_t value) { + memcpy(buf + *off, &value, sizeof(value)); + *off += sizeof(value); +} + +static void append_wire_str(unsigned char* buf, size_t* off, const char* s) { + size_t n = strlen(s); + memcpy(buf + *off, &n, sizeof(n)); + *off += sizeof(n); + memcpy(buf + *off, s, n); + *off += n; +} + +static void build_canonical_manifest(void) { + g_manifest_ready = 1; + size_t off = 0; + append_int32(g_manifest, &off, 2); + g_len_after_count = off; + append_wire_str(g_manifest, &off, "keep/a"); + append_wire_str(g_manifest, &off, "keep/b"); + g_len_after_keeps = off; + append_int32(g_manifest, &off, 1); + append_wire_str(g_manifest, &off, "excluded/prefix"); + g_len_after_protected = off; + append_int32(g_manifest, &off, 1); + append_wire_str(g_manifest, &off, "missing/path"); +} + +/* Best-effort non-blocking write: an oversized fuzz input is truncated rather + * than stalling the harness. */ +static void write_best_effort(int fd, const void* data, size_t size) { + const unsigned char* p = data; + size_t off = 0; + while (off < size) { + ssize_t n = write(fd, p + off, size - off); + if (n > 0) { + off += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) + continue; + break; + } +} + +/* Build prefix ++ data as a stream and drive receive_manifest_entries over it. + * The write half is shut down first so the parser always sees EOF instead of + * blocking on a missing frame tail. */ +static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data, + size_t size) { + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + return; + + int flags = fcntl(sv[0], F_GETFL, 0); + if (flags != -1) + (void)fcntl(sv[0], F_SETFL, flags | O_NONBLOCK); + + if (prefix_len > 0) + write_best_effort(sv[0], prefix, prefix_len); + if (size > 0) + write_best_effort(sv[0], data, size); + shutdown(sv[0], SHUT_WR); + + DeleteManifest* manifest = receive_manifest_entries(sv[1]); + delete_manifest_free(manifest); + + close(sv[0]); + close(sv[1]); +} + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (!g_manifest_ready) + build_canonical_manifest(); + + /* Raw bytes as the whole manifest. */ + receive_stream(NULL, 0, data, size); + + /* Keep the valid framing so the fuzzer reaches each later section. */ + receive_stream(g_manifest, g_len_after_protected, data, size); + receive_stream(g_manifest, g_len_after_keeps, data, size); + receive_stream(g_manifest, g_len_after_count, data, size); + + return 0; +} diff --git a/tests/fuzz/fuzz_protocol_framing.c b/tests/fuzz/fuzz_protocol_framing.c new file mode 100644 index 0000000..6bb0359 --- /dev/null +++ b/tests/fuzz/fuzz_protocol_framing.c @@ -0,0 +1,127 @@ +/* + * Fuzz the base protocol framing: receive_str / receive_data / receive_status + * (plus the redacted string, the size-limited data and the timed-status + * variants) fed arbitrary bytes over an in-memory socketpair. + * + * Every receive primitive reads a fixed-width header (a size_t string length, + * an unsigned long long data length, an int status/int value) and then a body. + * The fuzzer attacks: + * - oversized length headers (the MAX_STRING_SIZE / MAX_DATA_PAYLOAD_SIZE + * gates must reject before allocating), + * - truncated bodies (a declared body larger than the stream must fail + * cleanly at EOF, never read uninitialised memory or leak), + * - embedded NUL bytes in strings (must be refused), + * - out-of-range status enum values (status_to_string must stay in bounds). + * + * Each entry point gets its own socketpair because a single receive consumes a + * variable number of bytes from the stream; reusing one would make the later + * calls meaningless. The write half is shut down first so a truncated frame + * always terminates at EOF instead of blocking. + */ +#include "data.h" +#include "protocol.h" +#include +#include +#include +#include +#include +#include +#include + +static void write_best_effort(int fd, const void* data, size_t size) { + const unsigned char* p = data; + size_t off = 0; + while (off < size) { + ssize_t n = write(fd, p + off, size - off); + if (n > 0) { + off += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) + continue; + break; + } +} + +/* Create a socketpair pre-loaded with `data`, shut down the write half and + * return the read end (which the receiver reads from). `*write_end` is also + * returned so the caller can close it. */ +static int make_stream(const uint8_t* data, size_t size, int* write_end) { + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) { + *write_end = -1; + return -1; + } + int flags = fcntl(sv[0], F_GETFL, 0); + if (flags != -1) + (void)fcntl(sv[0], F_SETFL, flags | O_NONBLOCK); + if (size > 0) + write_best_effort(sv[0], data, size); + shutdown(sv[0], SHUT_WR); + *write_end = sv[0]; + return sv[1]; +} + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + int w; + + int rd = make_stream(data, size, &w); + if (rd >= 0) { + char* s = receive_str(rd); + free(s); + close(rd); + close(w); + } + + rd = make_stream(data, size, &w); + if (rd >= 0) { + char* s = receive_str_redacted(rd); + free(s); + close(rd); + close(w); + } + + rd = make_stream(data, size, &w); + if (rd >= 0) { + Data* d = receive_data(rd); + data_destroy(d); + close(rd); + close(w); + } + + /* The size-limited variant must reject anything beyond its explicit bound + * before allocating the body buffer. */ + rd = make_stream(data, size, &w); + if (rd >= 0) { + Data* d = receive_data_limited(rd, 256); + data_destroy(d); + close(rd); + close(w); + } + + rd = make_stream(data, size, &w); + if (rd >= 0) { + Status status = STATUS_OK; + (void)receive_status(rd, &status); + close(rd); + close(w); + } + + rd = make_stream(data, size, &w); + if (rd >= 0) { + Status status = STATUS_OK; + (void)receive_status_timed(rd, &status, 1); + close(rd); + close(w); + } + + rd = make_stream(data, size, &w); + if (rd >= 0) { + int value = 0; + (void)receive_int(rd, &value); + close(rd); + close(w); + } + + return 0; +} diff --git a/tests/fuzz/fuzz_xattr_block.c b/tests/fuzz/fuzz_xattr_block.c new file mode 100644 index 0000000..26e3775 --- /dev/null +++ b/tests/fuzz/fuzz_xattr_block.c @@ -0,0 +1,115 @@ +/* + * Fuzz the xattr wire block parser: xattr_receive(int fd, int* ok). + * + * The block is a count followed by that many (name_len, name, value_len, value) + * records. The receiver must reject an invalid count, an out-of-range or + * negative name/value length, an embedded NUL or non-whitelisted namespace in + * the name, an oversized value, and an aggregate payload beyond + * XATTR_TOTAL_MAX -- all without over-allocating or leaking. + * + * Raw bytes mostly stop at the first invalid count/length, so we also build a + * canonical, fully-valid two-entry block by hand and feed the receiver valid + * prefixes of it followed by the fuzz bytes. That drives the deep value- + * parsing and per-entry namespace/budget checks with attacker-controlled input. + */ +#include "protocol.h" +#include "xattr.h" +#include +#include +#include +#include +#include +#include +#include + +static unsigned char g_block[512]; +static size_t g_off_after_count; /* start of entry 0 */ +static size_t g_off_after_entry0; /* start of entry 1 */ +static size_t g_off_value0; /* start of the first value length */ +static int g_block_ready; + +static void append_int32(unsigned char* buf, size_t* off, int32_t value) { + memcpy(buf + *off, &value, sizeof(value)); + *off += sizeof(value); +} + +static void append_bytes(unsigned char* buf, size_t* off, const void* p, size_t n) { + if (n > 0) + memcpy(buf + *off, p, n); + *off += n; +} + +static void build_canonical_block(void) { + g_block_ready = 1; + size_t off = 0; + append_int32(g_block, &off, 2); + g_off_after_count = off; + + int32_t name0_len = (int32_t)strlen("user.foo"); + append_int32(g_block, &off, name0_len); + append_bytes(g_block, &off, "user.foo", (size_t)name0_len); + g_off_value0 = off; + append_int32(g_block, &off, 3); + append_bytes(g_block, &off, "bar", 3); + + g_off_after_entry0 = off; + int32_t name1_len = (int32_t)strlen("user.empty"); + append_int32(g_block, &off, name1_len); + append_bytes(g_block, &off, "user.empty", (size_t)name1_len); + append_int32(g_block, &off, 0); +} + +static void write_best_effort(int fd, const void* data, size_t size) { + const unsigned char* p = data; + size_t off = 0; + while (off < size) { + ssize_t n = write(fd, p + off, size - off); + if (n > 0) { + off += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) + continue; + break; + } +} + +static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data, + size_t size) { + int sv[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0) + return; + + int flags = fcntl(sv[0], F_GETFL, 0); + if (flags != -1) + (void)fcntl(sv[0], F_SETFL, flags | O_NONBLOCK); + + if (prefix_len > 0) + write_best_effort(sv[0], prefix, prefix_len); + if (size > 0) + write_best_effort(sv[0], data, size); + shutdown(sv[0], SHUT_WR); + + int ok = 0; + FileXattrList* list = xattr_receive(sv[1], &ok); + xattr_list_free(list); + + close(sv[0]); + close(sv[1]); +} + +int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (!g_block_ready) + build_canonical_block(); + + /* Raw bytes as the whole block. */ + receive_stream(NULL, 0, data, size); + + /* Valid framing so the fuzzer mutates the entry list, the first value and + * the second entry respectively instead of stopping at the count. */ + receive_stream(g_block, g_off_after_entry0, data, size); + receive_stream(g_block, g_off_value0, data, size); + receive_stream(g_block, g_off_after_count, data, size); + + return 0; +} diff --git a/tests/integration/test_fault_injection.py b/tests/integration/test_fault_injection.py new file mode 100644 index 0000000..8978daf --- /dev/null +++ b/tests/integration/test_fault_injection.py @@ -0,0 +1,367 @@ +"""Fault injection: the server must survive truncated / corrupted protocol +frames and abrupt mid-frame disconnects, and keep serving later connections. + +These tests deliberately speak raw bytes to a real server process: + + * malformed frames before/inside the config handshake (oversized length + headers, truncated string bodies, outright garbage), + * a captured *valid* config frame replayed so the connection reaches the + operation loop, followed by a partial ``STATUS_MANIFEST`` frame that is cut + mid-body and dropped, and + * a real client run relayed through a proxy that truncates the stream at a + range of byte offsets and resets both ends. + +After every fault the server process is asserted alive and a subsequent +ordinary transfer must complete and verify, proving the accept loop and +per-connection children recovered cleanly. All interactions are bounded by +short socket timeouts (no sleeps). +""" +import os +import select +import shutil +import socket +import struct +import subprocess +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +from common import ( # noqa: E402 + ServerManager, + TEST_DATA_DIR, + get_dest_received_dir, + run_client, + verify_transfer, +) + +PROTOCOL_VERSION = b"2.20.0" +STATUS_MANIFEST = 5 +STATUS_OK = 0 + +SOURCE_DIR = os.path.join(TEST_DATA_DIR, "fault_src") +DEST_DIR = os.path.join(TEST_DATA_DIR, "fault_dst") + + +@pytest.fixture(scope="module") +def fault_server(): + """A dedicated server so the aliveness assertions observe exactly the + process these faults were sent to.""" + server = ServerManager() + server.start() + yield server + server.stop() + + +@pytest.fixture(scope="module", autouse=True) +def _seed_source(): + if os.path.exists(SOURCE_DIR): + shutil.rmtree(SOURCE_DIR) + os.makedirs(os.path.join(SOURCE_DIR, "nested")) + with open(os.path.join(SOURCE_DIR, "hello.txt"), "wb") as fh: + fh.write(b"fault injection payload\n" * 64) + with open(os.path.join(SOURCE_DIR, "nested", "deep.bin"), "wb") as fh: + fh.write(bytes(range(256)) * 16) + yield + shutil.rmtree(SOURCE_DIR, ignore_errors=True) + shutil.rmtree(DEST_DIR, ignore_errors=True) + + +def _assert_alive(server): + assert server._proc is not None, "server process missing" + assert server._proc.poll() is None, ( + f"server exited with {server._proc.returncode} after fault injection" + ) + + +def _recover(server, label): + """Run one ordinary transfer and verify it end-to-end.""" + shutil.rmtree(DEST_DIR, ignore_errors=True) + os.makedirs(DEST_DIR) + result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=["--preserve"], port=server.port) + assert result.returncode == 0, ( + f"{label}: recovery transfer failed rc={result.returncode}: " + f"{(result.stderr or result.stdout)[:200]}" + ) + received = get_dest_received_dir(DEST_DIR, SOURCE_DIR) + mismatches, missing = verify_transfer(SOURCE_DIR, received) + assert not missing, f"{label}: recovery missing {missing}" + assert not mismatches, f"{label}: recovery mismatch {mismatches}" + + +def _abrupt_close(sock): + """Force an RST instead of a graceful FIN, the nastier mid-frame drop.""" + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except OSError: + pass + try: + sock.close() + except OSError: + pass + + +def _raw_connect(server): + sock = socket.create_connection(("127.0.0.1", server.port), timeout=5) + sock.settimeout(5) + return sock + + +def _recv_exact(sock, n): + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +# --- faults before/inside the config handshake ----------------------------- + +CONFIG_HANDSHAKE_FAULTS = { + "empty": b"", + # Length header claims a 1 EiB string body that never arrives. + "oversized_length": struct.pack("server connection and record the client's config + frame (all client bytes forwarded before the server's first reply).""" + + def __init__(self, target_port): + self.target = ("127.0.0.1", target_port) + self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(("127.0.0.1", 0)) + self.listener.listen(1) + self.listener.settimeout(20) + self.port = self.listener.getsockname()[1] + self.config_frame = None + + def run(self, cmd): + def serve(): + try: + client, _ = self.listener.accept() + except OSError: + return + try: + backend = socket.create_connection(self.target, timeout=10) + except OSError: + client.close() + return + client.settimeout(20) + backend.settimeout(20) + buf_c = bytearray() + seen_server = False + try: + while True: + ready, _, _ = select.select([client, backend], [], [], 20) + if not ready: + break + done = False + for sock in ready: + data = sock.recv(65536) + if not data: + done = True + continue + if sock is client: + buf_c += data + backend.sendall(data) + else: + if not seen_server: + seen_server = True + self.config_frame = bytes(buf_c) + client.sendall(data) + if done: + break + except OSError: + pass + finally: + client.close() + backend.close() + + thread = threading.Thread(target=serve) + thread.start() + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + thread.join(20) + return result + + def close(self): + try: + self.listener.close() + except OSError: + pass + + +@pytest.fixture(scope="module") +def captured_config(fault_server): + """Capture the config frame of one real client run through a relay.""" + proxy = _CaptureProxy(fault_server.port) + cmd = [ + os.path.join(os.path.dirname(__file__), "..", "..", "build", "client"), + "--source-dir", + SOURCE_DIR, + "--dest-dir", + DEST_DIR, + "--save-to-disk", + "--server-port", + str(proxy.port), + ] + try: + result = proxy.run(cmd) + assert result.returncode == 0, ( + f"capture run failed rc={result.returncode}: " + f"{(result.stderr or result.stdout)[:200]}" + ) + assert proxy.config_frame, "failed to capture the client config frame" + yield proxy.config_frame + finally: + proxy.close() + + +class TestTruncatedStatusFrame: + def test_partial_manifest_frame_then_drop(self, fault_server, captured_config): + sock = _raw_connect(fault_server) + sock.sendall(captured_config) + ack = _recv_exact(sock, 4) + assert ack is not None, "server closed before the config ack" + (status,) = struct.unpack("= self.max_client_bytes: + break + except (OSError, socket.timeout): + pass + for sock in (client, backend): + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except OSError: + pass + try: + sock.close() + except OSError: + pass + + thread = threading.Thread(target=serve) + thread.start() + try: + subprocess.run(cmd, capture_output=True, text=True, timeout=30) + finally: + thread.join(20) + self.listener.close() + + +class TestAbruptMidTransferDisconnect: + def test_client_stream_cut_at_offsets(self, fault_server, captured_config): + """Cut the real client stream at offsets anchored to the config frame's + actual size: mid-config, right after the config, and into the operation + stream -- each followed by an RST of both ends.""" + config_len = len(captured_config) + cuts = sorted({max(1, config_len // 2), max(1, config_len - 1), config_len + 8, + config_len + 256}) + for cut in cuts: + proxy = _TruncatingProxy(fault_server.port, cut) + cmd = [ + os.path.join(os.path.dirname(__file__), "..", "..", "build", "client"), + "--source-dir", + SOURCE_DIR, + "--dest-dir", + DEST_DIR, + "--save-to-disk", + "--server-port", + str(proxy.port), + ] + # The client is expected to fail; what matters is the server survives. + proxy.run(cmd) + _assert_alive(fault_server) + _recover(fault_server, "abrupt mid-transfer disconnects") diff --git a/tests/runner.c b/tests/runner.c index 51938e1..9e12323 100644 --- a/tests/runner.c +++ b/tests/runner.c @@ -16,6 +16,7 @@ #include "test_file_sendfile.h" #include "test_fuzz_smoke.h" #include "test_glob.h" +#include "test_hardlink.h" #include "test_iconv.h" #include "test_log.h" #include "test_metadata.h" @@ -88,6 +89,7 @@ int main() { RUN_TEST(test_server_cli); RUN_TEST(test_fuzz_smoke); RUN_TEST(test_xattr); + RUN_TEST(test_hardlink); printf("\n\033[1;36m=== TEST SUMMARY ===\033[0m\n"); printf("Total Tests Run: %d\n", tests_run); diff --git a/tests/test_hardlink.c b/tests/test_hardlink.c new file mode 100644 index 0000000..054e285 --- /dev/null +++ b/tests/test_hardlink.c @@ -0,0 +1,203 @@ +#include "test_hardlink.h" +#include "hardlink.h" +#include "test_utils.h" +#include +#include +#include +#include + +/* A fresh table starts empty and destroy accepts NULL / a fresh table. */ +static void test_hardlink_create_destroy() { + hardlink_table_destroy(NULL); + + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + EXPECT_EQ_INT((int)table->count, 0); + EXPECT_EQ_INT((int)table->capacity, 0); + EXPECT_EQ_INT(table->next_gid, 1); + hardlink_table_destroy(table); +} + +/* The first member of an (dev, ino) group is data-carrying and owns the group; + * every later member gets the SAME gid, is not first, and points back at the + * first member's wire path. */ +static void test_hardlink_grouping() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + int gid_a = -1, gid_b = -1; + bool first_a = false, first_b = false; + char* first_path_a = NULL; + char* first_path_b = NULL; + + EXPECT_TRUE( + hardlink_table_assign(table, "dir/first.txt", 7, 42, &gid_a, &first_a, &first_path_a)); + EXPECT_TRUE(first_a); + EXPECT_EQ_INT(gid_a, 1); + EXPECT_NOT_NULL(first_path_a); + EXPECT_EQ_STR(first_path_a, "dir/first.txt"); + + EXPECT_TRUE( + hardlink_table_assign(table, "dir/second.txt", 7, 42, &gid_b, &first_b, &first_path_b)); + EXPECT_FALSE(first_b); + EXPECT_EQ_INT(gid_b, gid_a); + EXPECT_NOT_NULL(first_path_b); + EXPECT_EQ_STR(first_path_b, "dir/first.txt"); + + /* Two members map onto a single stored group. */ + EXPECT_EQ_INT((int)table->count, 1); + EXPECT_EQ_INT(table->next_gid, 2); + + free(first_path_a); + free(first_path_b); + hardlink_table_destroy(table); +} + +/* A different inode on the same device is a distinct group with a fresh gid. */ +static void test_hardlink_distinct_inode() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + int gid1 = -1, gid2 = -1; + bool first1 = false, first2 = false; + char* path1 = NULL; + char* path2 = NULL; + + EXPECT_TRUE(hardlink_table_assign(table, "a", 7, 100, &gid1, &first1, &path1)); + EXPECT_TRUE(first1); + EXPECT_TRUE(hardlink_table_assign(table, "b", 7, 101, &gid2, &first2, &path2)); + EXPECT_TRUE(first2); + EXPECT_TRUE(gid1 != gid2); + EXPECT_EQ_INT(gid1, 1); + EXPECT_EQ_INT(gid2, 2); + EXPECT_EQ_STR(path1, "a"); + EXPECT_EQ_STR(path2, "b"); + + free(path1); + free(path2); + hardlink_table_destroy(table); +} + +/* Identical (dev, ino) on a DIFFERENT device must never be conflated: inode + * numbers are only unique per filesystem, so grouping is scoped by st_dev. */ +static void test_hardlink_distinct_device() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + int gid1 = -1, gid2 = -1; + bool first1 = false, first2 = false; + char* path1 = NULL; + char* path2 = NULL; + + EXPECT_TRUE(hardlink_table_assign(table, "dev_a/one", 1, 55, &gid1, &first1, &path1)); + EXPECT_TRUE(hardlink_table_assign(table, "dev_b/one", 2, 55, &gid2, &first2, &path2)); + EXPECT_TRUE(first1); + EXPECT_TRUE(first2); + EXPECT_TRUE(gid1 != gid2); + EXPECT_EQ_INT((int)table->count, 2); + + free(path1); + free(path2); + hardlink_table_destroy(table); +} + +/* The table owns deep copies of every path: mutating (or freeing) the caller's + * buffer after assign must not affect the stored / returned paths. */ +static void test_hardlink_path_ownership() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + char caller[] = "owned/path"; + int gid = -1; + bool is_first = false; + char* out = NULL; + + EXPECT_TRUE(hardlink_table_assign(table, caller, 3, 9, &gid, &is_first, &out)); + EXPECT_TRUE(is_first); + /* The returned pointer is a distinct allocation, not the caller's buffer. */ + EXPECT_TRUE(out != caller); + EXPECT_TRUE(table->items[0].first_path != caller); + + memset(caller, 'X', sizeof(caller) - 1); + EXPECT_EQ_STR(out, "owned/path"); + EXPECT_EQ_STR(table->items[0].first_path, "owned/path"); + + /* Later members get their own independent copy of the first path. */ + char second_caller[] = "owned/second"; + int gid2 = -1; + bool first2 = true; + char* out2 = NULL; + EXPECT_TRUE(hardlink_table_assign(table, second_caller, 3, 9, &gid2, &first2, &out2)); + EXPECT_FALSE(first2); + EXPECT_EQ_STR(out2, "owned/path"); + EXPECT_TRUE(out2 != table->items[0].first_path); + EXPECT_TRUE(out2 != out); + + free(out); + free(out2); + hardlink_table_destroy(table); +} + +/* Bad arguments must be rejected without touching the table. */ +static void test_hardlink_reject_bad_args() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + int gid = 0; + bool is_first = false; + char* out = NULL; + + EXPECT_FALSE(hardlink_table_assign(NULL, "x", 1, 1, &gid, &is_first, &out)); + EXPECT_FALSE(hardlink_table_assign(table, NULL, 1, 1, &gid, &is_first, &out)); + EXPECT_FALSE(hardlink_table_assign(table, "x", 1, 1, NULL, &is_first, &out)); + EXPECT_FALSE(hardlink_table_assign(table, "x", 1, 1, &gid, NULL, &out)); + EXPECT_FALSE(hardlink_table_assign(table, "x", 1, 1, &gid, &is_first, NULL)); + EXPECT_EQ_INT((int)table->count, 0); + + hardlink_table_destroy(table); +} + +/* Many distinct groups grow the item array through its realloc path and keep + * gid assignment stable and monotonic. */ +static void test_hardlink_many_groups() { + HardLinkTable* table = hardlink_table_create(); + EXPECT_NOT_NULL(table); + + const int n = 200; + for (int i = 0; i < n; i++) { + int gid = -1; + bool is_first = false; + char* out = NULL; + char path[32]; + snprintf(path, sizeof(path), "file_%d", i); + EXPECT_TRUE(hardlink_table_assign(table, path, 1, (ino_t)(1000 + i), &gid, &is_first, &out)); + EXPECT_TRUE(is_first); + EXPECT_EQ_INT(gid, i + 1); + EXPECT_EQ_STR(out, path); + free(out); + } + EXPECT_EQ_INT((int)table->count, n); + EXPECT_EQ_INT(table->next_gid, n + 1); + + /* Re-querying an existing inode still reports the original gid. */ + int gid = -1; + bool is_first = true; + char* out = NULL; + EXPECT_TRUE(hardlink_table_assign(table, "file_7_again", 1, 1007, &gid, &is_first, &out)); + EXPECT_FALSE(is_first); + EXPECT_EQ_INT(gid, 8); + EXPECT_EQ_STR(out, "file_7"); + free(out); + + hardlink_table_destroy(table); +} + +void test_hardlink() { + test_hardlink_create_destroy(); + test_hardlink_grouping(); + test_hardlink_distinct_inode(); + test_hardlink_distinct_device(); + test_hardlink_path_ownership(); + test_hardlink_reject_bad_args(); + test_hardlink_many_groups(); +} diff --git a/tests/test_hardlink.h b/tests/test_hardlink.h new file mode 100644 index 0000000..c271154 --- /dev/null +++ b/tests/test_hardlink.h @@ -0,0 +1,6 @@ +#ifndef TEST_HARDLINK_H +#define TEST_HARDLINK_H + +void test_hardlink(void); + +#endif