diff --git a/scripts/pcap_offsets.py b/scripts/pcap_offsets.py new file mode 100755 index 0000000..c6a397b --- /dev/null +++ b/scripts/pcap_offsets.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Compute record-boundary-aligned byte offsets in a classic (non-pcapng) +.pcap file, for simulating a still-growing capture in regression tests. + +Usage: pcap_offsets.py + +Prints n_segments+1 offsets, one per line: 0 (start of body, i.e. just past +the 24-byte global header) ... file size (end), each guaranteed to land +exactly on a record boundary. Segment sizes are as even as possible by +record count. Exits nonzero with a message on stderr if the file isn't a +well-formed classic pcap (e.g. truncated mid-record, or pcapng). +""" +import struct +import sys + +MAGICS = { + 0xA1B2C3D4: ("<", False), # little-endian, microsecond + 0xA1B23C4D: ("<", False), # little-endian, nanosecond (same header shape) + 0xD4C3B2A1: (">", False), # big-endian, microsecond + 0x4D3CB2A1: (">", False), # big-endian, nanosecond +} + + +def record_offsets(path): + with open(path, "rb") as f: + data = f.read() + if len(data) < 24: + sys.exit(f"file too short to contain a pcap global header: {path}") + magic = struct.unpack(" len(data): + sys.exit(f"file ends mid-record-header at offset {pos}: {path}") + incl_len = struct.unpack(endian + "I", data[pos + 8 : pos + 12])[0] + record_total = 16 + incl_len + if pos + record_total > len(data): + sys.exit(f"file ends mid-record-payload at offset {pos} (declared len {incl_len}): {path}") + pos += record_total + offsets.append(pos) + return offsets + + +def main(): + if len(sys.argv) != 3: + sys.exit(f"usage: {sys.argv[0]} ") + path = sys.argv[1] + n = int(sys.argv[2]) + if n < 1: + sys.exit("n_segments must be >= 1") + + offsets = record_offsets(path) # offsets[0]=24 (after header), offsets[-1]=file size + n_records = len(offsets) - 1 + if n_records < n: + sys.exit(f"file only has {n_records} records, can't split into {n} segments") + + # n+1 cumulative-size boundaries, evenly spaced by record index, from + # "header only" (offsets[0] == 24) up to the whole file (offsets[-1]). + for i in range(0, n + 1): + record_idx = (n_records * i) // n + print(offsets[record_idx]) + + +if __name__ == "__main__": + main() diff --git a/scripts/regression_test.sh b/scripts/regression_test.sh new file mode 100755 index 0000000..e7d68ae --- /dev/null +++ b/scripts/regression_test.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Regression test for scpcap's growing-file tailer, run against a real +# remote host over ssh. Exercises two distinct writer behaviors, in both +# transfer directions, so the tailer is tested wherever it actually runs +# (locally, or in the remote --server process): +# +# 1. "normal growth": a well-behaved writer that only ever appends bytes +# (file length always equals real content) -- the original behavior +# this tool was built for. Must keep working. +# +# 2. "preallocated growth": a writer that reserves space ahead of its +# real write cursor (ftruncate-extend, reading back as zero) and fills +# it in place, only shrinking the file back down to the real size at +# rotation, immediately before renaming to final. This is the pattern +# that caused a hang + corrupted destination before the confirmed_len +# fix in src/chunker.rs / src/tail.rs -- see git log for the writeup. +# +# Requires: a real pcap file to source test data from (default: the first +# *.pcap file found in ~/pcap), and ssh access to a test host with an +# scpcap binary deployable to ~/bin/scpcap (this script builds and deploys +# it before testing). +# +# Usage: scripts/regression_test.sh [pcap-file] [user@host] + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC_PCAP="${1:-$(ls "$HOME"/pcap/*.pcap 2>/dev/null | head -1)}" +HOST="${2:-eric@locl.sh}" +REMOTE_EXE="bin/scpcap" +BIN="$REPO_ROOT/target/release/scpcap" +LOCAL_TEST="$HOME/scpcap_test" +REMOTE_TEST="scpcap_test" +PAD_BYTES=65536 +STAGE_DELAY=0.3 +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PASS=0 +FAIL=0 +log() { echo "[*] $*" >&2; } +pass() { echo " PASS: $*"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); } + +if [ -z "$SRC_PCAP" ] || [ ! -f "$SRC_PCAP" ]; then + echo "no source pcap file found (looked in ~/pcap/*.pcap); pass one explicitly" >&2 + exit 2 +fi +log "source capture: $SRC_PCAP ($(wc -c < "$SRC_PCAP") bytes)" + +log "building release binary" +(cd "$REPO_ROOT" && cargo build --release --quiet) || exit 1 +log "deploying to $HOST:~/$REMOTE_EXE" +scp -q "$BIN" "$HOST:$REMOTE_EXE" || exit 1 + +mkdir -p "$LOCAL_TEST/dest" +ssh "$HOST" "mkdir -p $REMOTE_TEST/dest" + +# --- Build a small, record-aligned test capture ----------------------------- + +TRUNCATED_SRC="$WORK/src.pcap" +head -c 8000000 "$SRC_PCAP" > "$WORK/src.raw" 2>/dev/null || cp "$SRC_PCAP" "$WORK/src.raw" +PREFIX_LEN=$(python3 - "$WORK/src.raw" <<'PYEOF' +import struct, sys +with open(sys.argv[1], "rb") as f: + data = f.read() +magic = struct.unpack("" +pos = 24 +while pos + 16 <= len(data): + incl_len = struct.unpack(endian + "I", data[pos + 8: pos + 12])[0] + total = 16 + incl_len + if pos + total > len(data): + break + pos += total +print(pos) +PYEOF +) +dd if="$WORK/src.raw" of="$TRUNCATED_SRC" bs=1 count="$PREFIX_LEN" status=none +log "test capture: $TRUNCATED_SRC ($(wc -c < "$TRUNCATED_SRC") bytes, record-aligned)" + +N_SEGMENTS=4 +mapfile -t OFFSETS < <(python3 "$REPO_ROOT/scripts/pcap_offsets.py" "$TRUNCATED_SRC" "$N_SEGMENTS") +FINAL_SIZE="${OFFSETS[-1]}" +log "growth stages (cumulative bytes): ${OFFSETS[*]}" + +sha() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; } +EXPECTED_SHA="$(sha "$TRUNCATED_SRC")" + +# --- Target primitives: "local:" or "remote:" ------------------- + +t_write() { # target prev len + local target="$1" prev="$2" len="$3" + case "$target" in + local:*) + local path="${target#local:}" + python3 -c " +with open('$TRUNCATED_SRC','rb') as s, open('$path','r+b') as d: + s.seek($prev); d.seek($prev); d.write(s.read($len)) +" + ;; + remote:*) + local path="${target#remote:}" + dd if="$TRUNCATED_SRC" bs=1 skip="$prev" count="$len" status=none | \ + ssh "$HOST" "python3 -c \" +import sys +with open('$path','r+b') as f: + f.seek($prev) + f.write(sys.stdin.buffer.read()) +\"" + ;; + esac +} + +t_set_len() { # target len + local target="$1" len="$2" + case "$target" in + local:*) python3 -c "open('${target#local:}','r+b').truncate($len)" ;; + remote:*) ssh "$HOST" "python3 -c \"open('${target#remote:}','r+b').truncate($len)\"" ;; + esac +} + +t_create_empty() { # target + local target="$1" + case "$target" in + local:*) : > "${target#local:}" ;; + remote:*) ssh "$HOST" ": > ${target#remote:}" ;; + esac +} + +t_rename() { # target_partial target_final + local partial="$1" final="$2" + case "$partial" in + local:*) mv "${partial#local:}" "${final#local:}" ;; + remote:*) ssh "$HOST" "mv ${partial#remote:} ${final#remote:}" ;; + esac +} + +# --- Test runner -------------------------------------------------------------- + +# $1 = name, $2 = "normal" | "preallocated", $3 = "local-to-remote" | "remote-to-local" +run_case() { + local name="$1" mode="$2" direction="$3" + log "=== $name ($direction, $mode) ===" + + if [ "$direction" = "local-to-remote" ]; then + local partial="local:$LOCAL_TEST/$name.pcap.partial" + local final="local:$LOCAL_TEST/$name.pcap" + local dest="$REMOTE_TEST/$name.pcap" + rm -f "$LOCAL_TEST/$name.pcap.partial" "$LOCAL_TEST/$name.pcap" + ssh "$HOST" "rm -f $dest $dest.partial" + + t_create_empty "$partial" + t_write "$partial" 0 "${OFFSETS[0]}" + # For preallocated growth, reserve the padded size right after the + # first real write, before the client ever gets a chance to look. + if [ "$mode" = preallocated ]; then + t_set_len "$partial" $((FINAL_SIZE + PAD_BYTES)) + fi + + "$BIN" --remote-exe "$REMOTE_EXE" "$LOCAL_TEST/$name.pcap.partial" "$HOST:$REMOTE_TEST/" & + local client_pid=$! + + local prev="${OFFSETS[0]}" + for off in "${OFFSETS[@]:1}"; do + sleep "$STAGE_DELAY" + t_write "$partial" "$prev" $((off - prev)) + prev="$off" + done + if [ "$mode" = preallocated ]; then + sleep "$STAGE_DELAY" + t_set_len "$partial" "$FINAL_SIZE" + fi + t_rename "$partial" "$final" + + wait "$client_pid" + local status=$? + local actual_sha + actual_sha="$(ssh "$HOST" "sha256sum $dest 2>/dev/null | awk '{print \$1}'" || true)" + else + local partial="remote:$REMOTE_TEST/$name.pcap.partial" + local final="remote:$REMOTE_TEST/$name.pcap" + local dest="$LOCAL_TEST/dest/$name.pcap" + ssh "$HOST" "rm -f $REMOTE_TEST/$name.pcap $REMOTE_TEST/$name.pcap.partial" + rm -f "$dest" "$dest.partial" + + t_create_empty "$partial" + t_write "$partial" 0 "${OFFSETS[0]}" + if [ "$mode" = preallocated ]; then + t_set_len "$partial" $((FINAL_SIZE + PAD_BYTES)) + fi + + "$BIN" --remote-exe "$REMOTE_EXE" "$HOST:$REMOTE_TEST/$name.pcap.partial" "$LOCAL_TEST/dest/" & + local client_pid=$! + + local prev="${OFFSETS[0]}" + for off in "${OFFSETS[@]:1}"; do + sleep "$STAGE_DELAY" + t_write "$partial" "$prev" $((off - prev)) + prev="$off" + done + if [ "$mode" = preallocated ]; then + sleep "$STAGE_DELAY" + t_set_len "$partial" "$FINAL_SIZE" + fi + t_rename "$partial" "$final" + + wait "$client_pid" + local status=$? + local actual_sha + actual_sha="$(sha "$dest" || true)" + fi + + if [ "$status" -eq 0 ] && [ "$actual_sha" = "$EXPECTED_SHA" ]; then + pass "$name ($direction): exit 0, sha matches" + else + fail "$name ($direction): exit=$status expected_sha=$EXPECTED_SHA actual_sha=$actual_sha" + fi +} + +run_case "normal_growth" normal "local-to-remote" +run_case "preallocated" preallocated "local-to-remote" +run_case "normal_growth_remote_src" normal "remote-to-local" +run_case "preallocated_remote_src" preallocated "remote-to-local" + +echo +echo "=== summary: $PASS passed, $FAIL failed ===" +exit $((FAIL > 0)) diff --git a/src/tail.rs b/src/tail.rs index 51f75c3..cb347ed 100644 --- a/src/tail.rs +++ b/src/tail.rs @@ -18,8 +18,10 @@ const READ_WINDOW: usize = 1024 * 1024; /// visible via `fstat` slightly apart from each other -- NOT a guess about /// whether the writer finished; the authoritative "writer is done" signal /// is the rename itself (`complete_signal`). Re-checked continuously (reset -/// on every observed length change), not a one-shot timer. -const FINALIZE_GRACE_PERIOD: Duration = Duration::from_millis(500); +/// on every observed length change), not a one-shot timer. 1s gives a +/// comfortable margin even for a writer whose header and payload writes +/// aren't atomic (if the header landed, the body should follow shortly). +const FINALIZE_GRACE_PERIOD: Duration = Duration::from_secs(1); pub enum SourceEvent { Chunk(ChunkKind, Vec),