Add a pcap-split tool

This commit is contained in:
2026-10-05 20:16:01 +02:00
parent 5b5cf211d0
commit c11c893845
13 changed files with 769 additions and 22 deletions
+91
View File
@@ -0,0 +1,91 @@
package pcap
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"slices"
)
// MaxRecordLen is the largest captured packet length Reader accepts; larger
// values are taken as a sign of a corrupt file.
const MaxRecordLen = 64 << 20
// ErrTruncated is returned by Reader.Next when the file ends in the middle of
// a packet record.
var ErrTruncated = errors.New("pcap: file ends with a truncated packet record")
// Reader reads a classic pcap file, in either byte order and with microsecond
// or nanosecond timestamps. It returns records in their raw on-disk form so
// they can be copied unchanged to another file with the same header.
type Reader struct {
r *bufio.Reader
order binary.ByteOrder
header [24]byte
linkType uint32
rec []byte
n int // records read
}
// NewReader reads and validates the pcap file header.
func NewReader(r io.Reader) (*Reader, error) {
pr := &Reader{r: bufio.NewReaderSize(r, 1<<20)}
if _, err := io.ReadFull(pr.r, pr.header[:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return nil, errors.New("not a pcap file: shorter than the 24 byte file header")
}
return nil, err
}
switch magic := binary.LittleEndian.Uint32(pr.header[0:4]); magic {
case 0xa1b2c3d4, 0xa1b23c4d: // microsecond, nanosecond
pr.order = binary.LittleEndian
case 0xd4c3b2a1, 0x4d3cb2a1:
pr.order = binary.BigEndian
case 0x0a0d0d0a:
return nil, errors.New("this is a pcapng file, which is not supported; convert it with: editcap -F pcap in.pcapng out.pcap")
case 0xa1b2cd34, 0x34cdb2a1:
return nil, errors.New("modified (Kuznetzov) pcap files are not supported; convert with: editcap -F pcap in out.pcap")
default:
return nil, fmt.Errorf("not a pcap file: unknown magic number 0x%08x", magic)
}
pr.linkType = pr.order.Uint32(pr.header[20:24])
return pr, nil
}
// Header returns the raw 24 byte file header.
func (r *Reader) Header() []byte { return r.header[:] }
// LinkType returns the link-layer header type from the file header.
func (r *Reader) LinkType() uint32 { return r.linkType }
// Next returns the next raw packet record: the 16 byte record header followed
// by the captured data. The slice is only valid until the next call. At the
// end of the file Next returns io.EOF, or ErrTruncated if the last record is
// incomplete.
func (r *Reader) Next() ([]byte, error) {
if cap(r.rec) < 16 {
r.rec = make([]byte, 16, 64*1024)
}
r.rec = r.rec[:16]
if _, err := io.ReadFull(r.r, r.rec); err != nil {
if errors.Is(err, io.ErrUnexpectedEOF) {
return nil, ErrTruncated
}
return nil, err // io.EOF at a record boundary
}
inclLen := r.order.Uint32(r.rec[8:12])
if inclLen > MaxRecordLen {
return nil, fmt.Errorf("corrupt pcap file: packet %d has captured length %d", r.n+1, inclLen)
}
r.rec = slices.Grow(r.rec, int(inclLen))[:16+inclLen]
if _, err := io.ReadFull(r.r, r.rec[16:]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return nil, ErrTruncated
}
return nil, err
}
r.n++
return r.rec, nil
}
+73
View File
@@ -0,0 +1,73 @@
package pcap
import (
"errors"
"io"
)
// SplitLimit bounds each output file of Split. Zero means no limit.
type SplitLimit struct {
Packets int // maximum packets per file
Bytes int64 // maximum file size, including the file and record headers
}
// SplitStats summarizes a Split.
type SplitStats struct {
Packets int // packets copied
Parts int // files created
Truncated bool // the input ended with an incomplete packet record, which was dropped
}
// Split copies the pcap file read from r into consecutive parts, each starting
// with the input's file header and holding whole packet records, so that no
// part exceeds the limit. A packet that is bigger than the size limit on its
// own goes into a part by itself. create is called to open part 1, 2, ...
func Split(r io.Reader, limit SplitLimit, create func(part int) (io.Writer, error)) (SplitStats, error) {
var st SplitStats
pr, err := NewReader(r)
if err != nil {
return st, err
}
var (
w io.Writer
nPackets int
nBytes int64
)
for {
rec, err := pr.Next()
if err == io.EOF {
break
}
if errors.Is(err, ErrTruncated) {
st.Truncated = true
break
}
if err != nil {
return st, err
}
full := limit.Packets > 0 && nPackets >= limit.Packets ||
limit.Bytes > 0 && nPackets > 0 && nBytes+int64(len(rec)) > limit.Bytes
if w == nil || full {
st.Parts++
if w, err = create(st.Parts); err != nil {
return st, err
}
if _, err := w.Write(pr.Header()); err != nil {
return st, err
}
nPackets, nBytes = 0, int64(len(pr.Header()))
}
if _, err := w.Write(rec); err != nil {
return st, err
}
nPackets++
nBytes += int64(len(rec))
st.Packets++
}
if st.Packets == 0 {
return st, errors.New("the pcap file contains no packets")
}
return st, nil
}
+181
View File
@@ -0,0 +1,181 @@
package pcap
import (
"bytes"
"encoding/binary"
"errors"
"io"
"slices"
"strings"
"testing"
"time"
)
// testFile builds a pcap file with one packet per entry of sizes.
func testFile(t *testing.T, sizes ...int) []byte {
t.Helper()
var buf bytes.Buffer
w, err := NewWriter(&buf, LinkTypeEthernet)
if err != nil {
t.Fatal(err)
}
for i, n := range sizes {
if err := w.WritePacket(time.Unix(int64(i), 0), bytes.Repeat([]byte{byte(i)}, n)); err != nil {
t.Fatal(err)
}
}
return buf.Bytes()
}
// splitParts runs Split and returns the parts' contents.
func splitParts(t *testing.T, in []byte, limit SplitLimit) ([][]byte, SplitStats, error) {
t.Helper()
var parts []*bytes.Buffer
st, err := Split(bytes.NewReader(in), limit, func(part int) (io.Writer, error) {
if part != len(parts)+1 {
t.Errorf("create(%d), want %d", part, len(parts)+1)
}
parts = append(parts, &bytes.Buffer{})
return parts[len(parts)-1], nil
})
out := make([][]byte, len(parts))
for i, p := range parts {
out[i] = p.Bytes()
}
return out, st, err
}
// packetSizes returns the captured lengths of the packets in a pcap file.
func packetSizes(t *testing.T, file []byte) []int {
t.Helper()
r, err := NewReader(bytes.NewReader(file))
if err != nil {
t.Fatal(err)
}
var sizes []int
for {
rec, err := r.Next()
if err == io.EOF {
return sizes
}
if err != nil {
t.Fatal(err)
}
sizes = append(sizes, len(rec)-16)
}
}
func TestSplit(t *testing.T) {
tests := []struct {
name string
sizes []int
limit SplitLimit
want [][]int // packet sizes per part
}{
{"by packets", []int{10, 20, 30, 40, 50}, SplitLimit{Packets: 2}, [][]int{{10, 20}, {30, 40}, {50}}},
{"by packets exact", []int{10, 20, 30, 40}, SplitLimit{Packets: 2}, [][]int{{10, 20}, {30, 40}}},
{"one part", []int{10, 20}, SplitLimit{Packets: 5}, [][]int{{10, 20}}},
// 24 byte header + 2*(16+20) = 96 fits exactly; a third packet does not.
{"by bytes", []int{20, 20, 20, 20, 20}, SplitLimit{Bytes: 96}, [][]int{{20, 20}, {20, 20}, {20}}},
{"oversized packet gets its own part", []int{10, 500, 10}, SplitLimit{Bytes: 100}, [][]int{{10}, {500}, {10}}},
{"both limits", []int{1, 1, 1, 100, 1}, SplitLimit{Packets: 2, Bytes: 100}, [][]int{{1, 1}, {1}, {100}, {1}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in := testFile(t, tt.sizes...)
parts, st, err := splitParts(t, in, tt.limit)
if err != nil {
t.Fatal(err)
}
if st.Parts != len(tt.want) || st.Packets != len(tt.sizes) || st.Truncated {
t.Errorf("stats %+v", st)
}
if len(parts) != len(tt.want) {
t.Fatalf("got %d parts, want %d", len(parts), len(tt.want))
}
var joined []byte
for i, p := range parts {
if !bytes.Equal(p[:24], in[:24]) {
t.Errorf("part %d: file header %x, want %x", i+1, p[:24], in[:24])
}
if got := packetSizes(t, p); !slices.Equal(got, tt.want[i]) {
t.Errorf("part %d: packet sizes %v, want %v", i+1, got, tt.want[i])
}
if tt.limit.Bytes > 0 && len(tt.want[i]) > 1 && int64(len(p)) > tt.limit.Bytes {
t.Errorf("part %d: %d bytes exceeds limit %d", i+1, len(p), tt.limit.Bytes)
}
joined = append(joined, p[24:]...)
}
if !bytes.Equal(joined, in[24:]) {
t.Error("parts do not add up to the input records")
}
})
}
}
func TestSplitTruncated(t *testing.T) {
in := testFile(t, 10, 20, 30)
parts, st, err := splitParts(t, in[:len(in)-5], SplitLimit{Packets: 10})
if err != nil {
t.Fatal(err)
}
if !st.Truncated || st.Packets != 2 || len(parts) != 1 {
t.Errorf("stats %+v", st)
}
}
func TestSplitErrors(t *testing.T) {
pcapng := []byte{0x0a, 0x0d, 0x0d, 0x0a, 0x1c, 0, 0, 0, 0x4d, 0x3c, 0x2b, 0x1a, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
corrupt := testFile(t, 10)
binary.LittleEndian.PutUint32(corrupt[24+8:], MaxRecordLen+1)
for name, tc := range map[string]struct {
in []byte
want string
}{
"empty input": {nil, "shorter than"},
"no packets": {testFile(t), "no packets"},
"pcapng": {pcapng, "pcapng"},
"garbage": {bytes.Repeat([]byte("x"), 40), "unknown magic"},
"corrupt": {corrupt, "corrupt"},
} {
_, _, err := splitParts(t, tc.in, SplitLimit{Packets: 1})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %q", name, err, tc.want)
}
}
}
func TestSplitCreateError(t *testing.T) {
boom := errors.New("boom")
_, err := Split(bytes.NewReader(testFile(t, 1, 1)), SplitLimit{Packets: 1}, func(part int) (io.Writer, error) {
if part == 2 {
return nil, boom
}
return io.Discard, nil
})
if !errors.Is(err, boom) {
t.Errorf("err %v, want %v", err, boom)
}
}
func TestReaderByteOrderAndNanoseconds(t *testing.T) {
// Big endian, nanosecond resolution, link type 101, one 2 byte packet.
in := []byte{
0xa1, 0xb2, 0x3c, 0x4d, 0, 2, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 0, 101,
0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 2, 0xaa, 0xbb,
}
r, err := NewReader(bytes.NewReader(in))
if err != nil {
t.Fatal(err)
}
if r.LinkType() != LinkTypeRaw {
t.Errorf("link type %d", r.LinkType())
}
rec, err := r.Next()
if err != nil || !bytes.Equal(rec, in[24:]) {
t.Errorf("record %x, %v", rec, err)
}
if _, err := r.Next(); err != io.EOF {
t.Errorf("err %v, want EOF", err)
}
}