diff --git a/internal/app/app.go b/internal/app/app.go index 513f872..1733dfc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -63,7 +63,7 @@ func Prepare(cfg *config.Config, opts PrepareOptions) (*Prepared, error) { // broken file should never cost you a link bounce. var frames generator.FrameSource if cfg.Mode == config.ModePCAP { - f, err := pcapfile.Load(cfg.PCAPFile) + f, err := pcapfile.Load(cfg.PCAPFile, pcapfile.Limits{MaxBytes: cfg.PCAPMemory}) if err != nil { return nil, err } diff --git a/internal/cli/flags.go b/internal/cli/flags.go index febadbd..4698a72 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -24,11 +24,12 @@ type RunFunc func(ctx context.Context, cfg *config.Config) error // options holds the raw string forms of flags that need custom parsing. type options struct { - pps string - bps string - rxPorts []string - etherHex string - forget bool + pps string + bps string + rxPorts []string + etherHex string + pcapMemory string + forget bool } // NewRootCommand builds the `wireblast` command. Running it with no flags at @@ -124,6 +125,9 @@ in L1, so --bps 10G means 10G line rate.`, f.StringVar((*string)(&cfg.PCAPTiming), "pcap-timing", string(cfg.PCAPTiming), "pcap pacing: rate (use --pps/--bps) or original (preserve capture timing)") f.BoolVar(&cfg.PCAPLoop, "pcap-loop", cfg.PCAPLoop, "loop the capture until the duration expires") + f.StringVar(&opt.pcapMemory, "pcap-memory", "", + "memory budget for loading the capture, in binary units (e.g. 512M, 4G; default 1G). "+ + "The whole capture is held in RAM, so the budget has to fit") f.BoolVar(&cfg.NoTUI, "no-tui", cfg.NoTUI, "skip the wizard and dashboard; validate the flags and run, printing periodic statistics") @@ -166,6 +170,13 @@ func applyOptions(cmd *cobra.Command, cfg *config.Config, opt *options) error { cfg.PPS = 0 } } + if f.Changed("pcap-memory") { + v, err := config.ParseSize(opt.pcapMemory) + if err != nil { + return fmt.Errorf("--pcap-memory: %w", err) + } + cfg.PCAPMemory = v + } if f.Changed("ethertype") { v, err := parseEtherType(opt.etherHex) if err != nil { diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index 6781973..4bb0af8 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -192,7 +192,7 @@ func TestEveryConfigFieldHasAFlag(t *testing.T) { "packet-size", "vlan", "ethertype", "payload-byte", "duration", "pps", "bps", "queues", "rx-mode", "rx-port", "rx-cidr", - "pcap", "pcap-timing", "pcap-loop", + "pcap", "pcap-timing", "pcap-loop", "pcap-memory", "no-tui", "start", "yes", "allow-match-all", "forget", } for _, name := range want { @@ -202,6 +202,19 @@ func TestEveryConfigFieldHasAFlag(t *testing.T) { } } +func TestPcapMemoryFlag(t *testing.T) { + got, err := parse(t, "--pcap", "x.pcap", "--pcap-memory", "4G") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got.PCAPMemory != 4<<30 { + t.Errorf("PCAPMemory = %d, want %d", got.PCAPMemory, uint64(4<<30)) + } + if _, err := parse(t, "--pcap-memory", "banana"); err == nil { + t.Error("a nonsense size should fail") + } +} + func TestUnknownFlagAndArgsRejected(t *testing.T) { if _, err := parse(t, "--nope"); err == nil { t.Error("unknown flag should fail") diff --git a/internal/config/config.go b/internal/config/config.go index f5548a2..17386f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -170,6 +170,10 @@ type Config struct { PCAPFile string PCAPTiming PcapTiming PCAPLoop bool + // PCAPMemory is the memory budget in bytes for loading the capture (frame + // bytes plus index). 0 means the 1 GiB default; the whole capture is held + // in RAM, so a raised budget has to fit in it. + PCAPMemory uint64 // Front-end and confirmation. NoTUI bool @@ -335,6 +339,11 @@ func (c *Config) Validate() error { if c.PCAPTiming != PcapRate && c.PCAPTiming != PcapOriginal { bad("--pcap-timing %q is not one of rate, original", c.PCAPTiming) } + // A tiny budget is almost certainly a unit slip ("4" meaning 4 bytes + // rather than 4G), so refuse it rather than reject every capture. + if c.PCAPMemory != 0 && c.PCAPMemory < 1<<20 { + bad("--pcap-memory %d bytes is smaller than 1 MiB; give a size like 512M or 4G", c.PCAPMemory) + } } if c.Duration < 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 466ea49..515a6e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -122,6 +122,12 @@ func TestValidate(t *testing.T) { {"pcap bad timing", func(c *Config) { c.Mode, c.PCAPFile, c.PCAPTiming = ModePCAP, "x.pcap", "asap" }, "--pcap-timing"}, + {"pcap memory too small", func(c *Config) { + c.Mode, c.PCAPFile, c.PCAPMemory = ModePCAP, "x.pcap", 4 + }, "--pcap-memory"}, + {"pcap memory ok", func(c *Config) { + c.Mode, c.PCAPFile, c.PCAPMemory = ModePCAP, "x.pcap", 4<<30 + }, ""}, {"pcap ignores packet size", func(c *Config) { c.Mode, c.PCAPFile, c.PacketSize = ModePCAP, "x.pcap", 0 }, ""}, diff --git a/internal/config/parse.go b/internal/config/parse.go index 1e9166f..1179a52 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -187,6 +187,100 @@ func FormatBPS(bps uint64) string { return formatSI(float64(bps)) + "bps" } +// ParseSize parses a memory size in bytes. It accepts a plain number and the +// suffixes k/K, m/M, g/G and t/T, optionally followed by "B" or "iB". Unlike +// ParsePPS and ParseBPS the multipliers are binary (1k = 1024), because memory +// is quoted in powers of two: the default capture budget is 1<<30, and a +// decimal "1G" would silently be smaller than that default. +// +// ParseSize("1073741824") == 1 << 30 +// ParseSize("512M") == 512 << 20 +// ParseSize("4G") == 4 << 30 +// ParseSize("4GiB") == 4 << 30 +// ParseSize("1.5G") == 3 << 29 +// +// There is no "unlimited": a size of zero or the unlimited words are errors, +// because an unbounded load is exactly what a memory budget exists to prevent. +func ParseSize(s string) (uint64, error) { + t := strings.ToLower(strings.TrimSpace(s)) + if t == "" { + return 0, errors.New("empty size value") + } + if unlimitedWords[t] { + return 0, fmt.Errorf("%q is not a size; give a byte count like 512M or 4G", s) + } + // Strip a trailing unit, longest first so "gib" is consumed before the "g" + // multiplier is looked for. + for _, u := range []string{"ib", "b"} { + if strings.HasSuffix(t, u) { + t = strings.TrimSpace(strings.TrimSuffix(t, u)) + break + } + } + mult := uint64(1) + if t != "" { + switch t[len(t)-1] { + case 'k': + mult, t = 1<<10, t[:len(t)-1] + case 'm': + mult, t = 1<<20, t[:len(t)-1] + case 'g': + mult, t = 1<<30, t[:len(t)-1] + case 't': + mult, t = 1<<40, t[:len(t)-1] + } + } + t = strings.TrimSpace(t) + if t == "" { + return 0, fmt.Errorf("%q is not a size", s) + } + var n uint64 + // Integers exactly; anything with a decimal point via float. + if !strings.ContainsAny(t, ".eE") { + v, err := strconv.ParseUint(t, 10, 64) + if err != nil { + return 0, fmt.Errorf("%q is not a size", s) + } + if v > math.MaxUint64/mult { + return 0, fmt.Errorf("%q is too large a size", s) + } + n = v * mult + } else { + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return 0, fmt.Errorf("%q is not a size", s) + } + if f < 0 { + return 0, fmt.Errorf("%q is negative", s) + } + if prod := f * float64(mult); prod > float64(math.MaxUint64) { + return 0, fmt.Errorf("%q is too large a size", s) + } + n = uint64(f * float64(mult)) + } + if n == 0 { + return 0, fmt.Errorf("%q is not a usable size", s) + } + return n, nil +} + +// FormatSize renders a byte count in the binary units ParseSize accepts. +func FormatSize(n uint64) string { + v := float64(n) + switch { + case v >= 1<<40: + return trimZeros(strconv.FormatFloat(v/(1<<40), 'f', 2, 64)) + "T" + case v >= 1<<30: + return trimZeros(strconv.FormatFloat(v/(1<<30), 'f', 2, 64)) + "G" + case v >= 1<<20: + return trimZeros(strconv.FormatFloat(v/(1<<20), 'f', 2, 64)) + "M" + case v >= 1<<10: + return trimZeros(strconv.FormatFloat(v/(1<<10), 'f', 2, 64)) + "k" + default: + return strconv.FormatUint(n, 10) + } +} + func formatSI(v float64) string { switch { case v >= 1e9: diff --git a/internal/config/parse_test.go b/internal/config/parse_test.go index e09255c..7956048 100644 --- a/internal/config/parse_test.go +++ b/internal/config/parse_test.go @@ -77,6 +77,69 @@ func TestParseBPS(t *testing.T) { } } +func TestParseSize(t *testing.T) { + tests := []struct { + in string + want uint64 + wantErr bool + }{ + {"1073741824", 1 << 30, false}, + {"512M", 512 << 20, false}, + {"512m", 512 << 20, false}, + {"4G", 4 << 30, false}, + {"4g", 4 << 30, false}, + {"4GB", 4 << 30, false}, + {"4GiB", 4 << 30, false}, + {"4096MB", 4096 << 20, false}, + {"1.5G", 3 << 29, false}, + {"2T", 2 << 40, false}, + {"64k", 64 << 10, false}, + {" 4G ", 4 << 30, false}, + {"100", 100, false}, + // Sizes have no "unlimited": a budget of nothing is not a budget. + {"0", 0, true}, + {"unlimited", 0, true}, + {"max", 0, true}, + {"", 0, true}, + {"-1G", 0, true}, + {"xyz", 0, true}, + {"1X", 0, true}, + {"99999999999999999999", 0, true}, // exceeds uint64 on its own + {"18446744073t", 0, true}, // fits uint64 until the multiplier + {"1e40", 0, true}, // float path overflow + } + for _, tt := range tests { + got, err := ParseSize(tt.in) + if (err != nil) != tt.wantErr { + t.Errorf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr) + continue + } + if err == nil && got != tt.want { + t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + in uint64 + want string + }{ + {1 << 30, "1G"}, + {512 << 20, "512M"}, + {4 << 30, "4G"}, + {3 << 29, "1.5G"}, + {2 << 40, "2T"}, + {64 << 10, "64k"}, + {100, "100"}, + } + for _, tt := range tests { + if got := FormatSize(tt.in); got != tt.want { + t.Errorf("FormatSize(%d) = %q, want %q", tt.in, got, tt.want) + } + } +} + func TestFormatRates(t *testing.T) { tests := []struct { pps uint64 diff --git a/internal/pcapfile/pcapfile.go b/internal/pcapfile/pcapfile.go index cdceeb2..8d6e148 100644 --- a/internal/pcapfile/pcapfile.go +++ b/internal/pcapfile/pcapfile.go @@ -14,12 +14,15 @@ import ( "errors" "fmt" "io" + "math" "os" "time" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/layers" "github.com/gopacket/gopacket/pcapgo" + + "github.com/atoonk/wireblast/internal/config" ) // Limits on what will be loaded. @@ -32,27 +35,33 @@ const ( // stays comfortably inside how many frames one packet may span, and // preflight rejects a capture that does not. MaxFrame = 16384 - // MaxPackets bounds how many frames a capture may hold, so pointing - // Wireblast at a capture with millions of tiny records fails politely - // instead of exhausting the machine. - MaxPackets = 2_000_000 + // DefaultMaxBytes is the memory budget a capture may occupy when the + // caller does not choose one. The whole capture is held in RAM (which this + // runs as root on), so an unbounded load could OOM the host before the NIC + // is ever touched; anyone with more memory can raise the budget with + // --pcap-memory. + DefaultMaxBytes = 1 << 30 // 1 GiB + // recordBytes is what one index entry costs against the budget. A test + // asserts it matches unsafe.Sizeof(record{}), so the accounting cannot + // drift from the real struct. Charging the index means the budget also + // bounds packet count: a capture of nothing but minimum-size frames costs + // MinFrame+recordBytes per packet. + recordBytes = 24 ) -// maxLoadBytes bounds the total bytes a capture may occupy in memory. The -// per-frame and per-count limits above are not enough on their own: two million -// maximum-size frames would be tens of gigabytes, enough to OOM the host (which -// this runs as root on) before the NIC is ever touched. -// -// It must stay below 1<<32: record offsets are stored as uint32, so a total -// under 4 GiB is what guarantees they can never wrap. A var rather than a const -// only so a test can lower it. -var maxLoadBytes = 1 << 30 // 1 GiB - -// record is one frame's place in the flat backing store. off and len are -// uint32 to keep the index compact for a multi-million-record capture; the -// maxLoadBytes cap (kept below 4 GiB) is what guarantees neither can wrap. +// Limits is what the caller allows Load to hold in memory. The zero value +// means the defaults. +type Limits struct { + // MaxBytes is the memory budget for the capture, counting frame bytes and + // the per-frame index. Zero means DefaultMaxBytes. + MaxBytes uint64 +} + +// record is one frame's place in the flat backing store. len fits a uint32 +// because no frame exceeds MaxFrame; off is a uint64 so a raised memory +// budget can hold captures past 4 GiB. type record struct { - off uint32 + off uint64 len uint32 // gap is the time between the previous frame and this one, as the capture // recorded it. Zero for the first frame. @@ -82,7 +91,15 @@ type File struct { } // Load reads and validates a capture file. -func Load(path string) (*File, error) { +func Load(path string, lim Limits) (*File, error) { + maxBytes := lim.MaxBytes + if maxBytes == 0 { + maxBytes = DefaultMaxBytes + } + // Slice lengths are ints, so on a 32-bit platform the budget can never + // exceed what a slice can address anyway. + maxBytes = min(maxBytes, math.MaxInt) + f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open capture: %w", err) @@ -101,9 +118,19 @@ func Load(path string) (*File, error) { } out := &File{path: path, minLen: MaxFrame + 1} + var fileSize int64 + if st, err := f.Stat(); err == nil { + fileSize = st.Size() + } + // The file size is a close overestimate of the frame bytes (per-record + // headers and all), so growing the store by append would peak at roughly + // twice the final size for nothing. Reserve once up front instead. + if fileSize > 0 { + out.data = make([]byte, 0, min(uint64(fileSize), maxBytes)) + } var first, prev time.Time for i := 0; ; i++ { - data, ci, err := src.ReadPacketData() + data, ci, err := src.ZeroCopyReadPacketData() if errors.Is(err, io.EOF) { break } @@ -116,14 +143,11 @@ func Load(path string) (*File, error) { } return nil, fmt.Errorf("%s: reading packet %d: %w", path, i+1, err) } - if len(out.recs) >= MaxPackets { - return nil, fmt.Errorf("%s holds more than %d packets, which is more than Wireblast "+ - "will load into memory. Trim it with `editcap -c %d`", path, MaxPackets, MaxPackets) - } - if len(out.data)+len(data) > maxLoadBytes { - return nil, fmt.Errorf("%s would load more than %d bytes into memory, more than "+ - "Wireblast will hold. Trim it with `editcap -c N` to keep fewer packets", - path, maxLoadBytes) + if uint64(len(out.data)+len(data))+uint64(len(out.recs)+1)*recordBytes > maxBytes { + return nil, fmt.Errorf("%s needs more than %s of memory to load (frame bytes plus "+ + "index). Raise the budget with --pcap-memory %s, or trim the capture with "+ + "`editcap -c N` to keep fewer packets", + path, config.FormatSize(maxBytes), config.FormatSize(suggestBudget(fileSize, maxBytes))) } switch { @@ -151,7 +175,7 @@ func Load(path string) (*File, error) { prev = ci.Timestamp } - out.recs = append(out.recs, record{off: uint32(len(out.data)), len: uint32(len(data)), gap: gap}) + out.recs = append(out.recs, record{off: uint64(len(out.data)), len: uint32(len(data)), gap: gap}) out.data = append(out.data, data...) out.minLen = min(out.minLen, len(data)) out.maxLen = max(out.maxLen, len(data)) @@ -167,6 +191,22 @@ func Load(path string) (*File, error) { return out, nil } +// suggestBudget picks a clean power-of-two budget the failed capture should +// fit inside, for the over-budget error message. +func suggestBudget(fileSize int64, maxBytes uint64) uint64 { + // The index costs a little more per record than the pcap's own framing, so + // 1.3x the file size covers even a capture of minimum-size frames. + need := maxBytes * 2 + if fileSize > 0 { + need = uint64(float64(fileSize) * 1.3) + } + s := uint64(1) << 20 + for s < need && s < 1<<62 { + s <<= 1 + } + return s +} + // openReader picks the pcap or pcapng reader by sniffing the file's magic. func openReader(r *bufio.Reader, path string) (packetReader, layers.LinkType, error) { magic, err := r.Peek(4) @@ -189,9 +229,13 @@ func openReader(r *bufio.Reader, path string) (packetReader, layers.LinkType, er return pr, pr.LinkType(), nil } -// packetReader is the bit of pcapgo's readers Wireblast uses. +// packetReader is the bit of pcapgo's readers Wireblast uses. The zero-copy +// variant returns a slice into the reader's own buffer, valid only until the +// next call; Load copies every frame into its contiguous store immediately, so +// nothing is retained, and a multi-gigabyte capture does not churn a second +// capture's worth of per-packet garbage through the heap. type packetReader interface { - ReadPacketData() ([]byte, gopacket.CaptureInfo, error) + ZeroCopyReadPacketData() ([]byte, gopacket.CaptureInfo, error) } // Len is how many frames the capture holds. @@ -201,7 +245,7 @@ func (f *File) Len() int { return len(f.recs) } // returned slice aliases the backing store and must not be modified. func (f *File) Frame(i int) ([]byte, time.Duration) { r := f.recs[i] - return f.data[r.off : r.off+r.len], r.gap + return f.data[r.off : r.off+uint64(r.len)], r.gap } // MaxLen is the largest frame in the capture, in bytes. diff --git a/internal/pcapfile/pcapfile_test.go b/internal/pcapfile/pcapfile_test.go index 1f56c63..02fe8b7 100644 --- a/internal/pcapfile/pcapfile_test.go +++ b/internal/pcapfile/pcapfile_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" "time" + "unsafe" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/layers" @@ -110,7 +111,7 @@ func TestLoadEthernetCapture(t *testing.T) { {data: frame(128, 0xa3), at: 25 * time.Millisecond}, }) - f, err := Load(path) + f, err := Load(path, Limits{}) if err != nil { t.Fatalf("Load: %v", err) } @@ -161,7 +162,7 @@ func TestLoadPcapng(t *testing.T) { {data: frame(64, 0xb1), at: 0}, {data: frame(300, 0xb2), at: 5 * time.Millisecond}, }) - f, err := Load(path) + f, err := Load(path, Limits{}) if err != nil { t.Fatalf("Load pcapng: %v", err) } @@ -177,7 +178,7 @@ func TestLoadPcapng(t *testing.T) { func TestRejectsNonEthernetLinkTypes(t *testing.T) { for _, lt := range []layers.LinkType{layers.LinkTypeRaw, layers.LinkTypeLinuxSLL} { path := writePcap(t, lt, []capPacket{{data: frame(64, 1)}}) - _, err := Load(path) + _, err := Load(path, Limits{}) if err == nil { t.Errorf("link type %v should be rejected", lt) continue @@ -198,21 +199,21 @@ func TestRejectsMalformedCaptures(t *testing.T) { if err := os.WriteFile(path, []byte("this is not a pcap file at all"), 0o644); err != nil { t.Fatal(err) } - _, err := Load(path) + _, err := Load(path, Limits{}) if err == nil || !strings.Contains(err.Error(), "not a readable pcap") { t.Fatalf("error = %v, want a clear 'not a pcap' message", err) } }) t.Run("missing file", func(t *testing.T) { - if _, err := Load(filepath.Join(dir, "nope.pcap")); err == nil { + if _, err := Load(filepath.Join(dir, "nope.pcap"), Limits{}); err == nil { t.Fatal("a missing file should fail") } }) t.Run("empty capture", func(t *testing.T) { path := writePcap(t, layers.LinkTypeEthernet, nil) - _, err := Load(path) + _, err := Load(path, Limits{}) if err == nil || !strings.Contains(err.Error(), "no packets") { t.Fatalf("error = %v, want a 'no packets' message", err) } @@ -223,7 +224,7 @@ func TestRejectsMalformedCaptures(t *testing.T) { {data: frame(64, 1)}, {data: make([]byte, 6)}, // too short to be Ethernet }) - _, err := Load(path) + _, err := Load(path, Limits{}) if err == nil { t.Fatal("a 6-byte record should be rejected") } @@ -239,39 +240,49 @@ func TestRejectsMalformedCaptures(t *testing.T) { path := writePcap(t, layers.LinkTypeEthernet, []capPacket{ {data: frame(MaxFrame+1, 1)}, }) - _, err := Load(path) + _, err := Load(path, Limits{}) if err == nil || !strings.Contains(err.Error(), "larger than") { t.Fatalf("error = %v, want a size-limit message", err) } }) } -// A capture whose frames are individually legal but sum to more than will fit -// in memory must be refused up front, not appended until the host OOMs. The -// byte cap is also what keeps the uint32 record offsets from wrapping. +// A capture whose frames are individually legal but sum to more than the +// memory budget must be refused up front, not appended until the host OOMs. func TestRejectsOversizedTotal(t *testing.T) { - orig := maxLoadBytes - defer func() { maxLoadBytes = orig }() - maxLoadBytes = 100 // two 64-byte frames already exceed this - + // One 64-byte frame costs 64+recordBytes = 88 against the budget, so 100 + // holds one frame but not two. path := writePcap(t, layers.LinkTypeEthernet, []capPacket{ {data: frame(64, 1)}, {data: frame(64, 2)}, }) - _, err := Load(path) + _, err := Load(path, Limits{MaxBytes: 100}) if err == nil { - t.Fatal("a capture larger than the byte cap must be rejected") + t.Fatal("a capture larger than the byte budget must be rejected") } - for _, want := range []string{"bytes", "editcap"} { + for _, want := range []string{"memory", "editcap", "--pcap-memory"} { if !strings.Contains(err.Error(), want) { t.Errorf("error = %q, should mention %q", err, want) } } + // The same capture loads once the budget is raised. + if _, err := Load(path, Limits{MaxBytes: 200}); err != nil { + t.Fatalf("a raised budget must load what a smaller one rejected: %v", err) + } + // A capture that fits still loads. small := writePcap(t, layers.LinkTypeEthernet, []capPacket{{data: frame(64, 1)}}) - if _, err := Load(small); err != nil { - t.Fatalf("a capture within the cap must still load: %v", err) + if _, err := Load(small, Limits{MaxBytes: 100}); err != nil { + t.Fatalf("a capture within the budget must still load: %v", err) + } +} + +// recordBytes is what Load charges the budget per index entry; it must not +// drift from what a record actually costs. +func TestRecordBytesMatchesStruct(t *testing.T) { + if got := unsafe.Sizeof(record{}); got != recordBytes { + t.Fatalf("unsafe.Sizeof(record{}) = %d, but the budget charges recordBytes = %d", got, recordBytes) } } @@ -281,7 +292,7 @@ func TestTruncatedFramesAreReported(t *testing.T) { {data: frame(64, 1)}, {data: frame(64, 2), origLen: 1514}, // captured 64 of a 1514-byte packet }) - f, err := Load(path) + f, err := Load(path, Limits{}) if err != nil { t.Fatal(err) } @@ -306,7 +317,7 @@ func TestOutOfOrderTimestampsGiveNoNegativeGaps(t *testing.T) { {data: frame(64, 2), at: 50 * time.Millisecond}, // earlier than the one before {data: frame(64, 3), at: 150 * time.Millisecond}, }) - f, err := Load(path) + f, err := Load(path, Limits{}) if err != nil { t.Fatal(err) } @@ -327,7 +338,7 @@ func TestValidationHappensAtLoadTime(t *testing.T) { {data: frame(64, 1)}, {data: make([]byte, 3)}, }) - if _, err := Load(path); err == nil { + if _, err := Load(path, Limits{}); err == nil { t.Fatal("Load must reject the capture up front, not at transmit time") } } @@ -348,7 +359,7 @@ func BenchmarkFrameLookup(b *testing.B) { } f.Close() - cap, err := Load(path) + cap, err := Load(path, Limits{}) if err != nil { b.Fatal(err) } diff --git a/internal/prefs/prefs.go b/internal/prefs/prefs.go index 9e1757b..f00de2f 100644 --- a/internal/prefs/prefs.go +++ b/internal/prefs/prefs.go @@ -260,6 +260,7 @@ func Merge(flags config.Config, saved config.Config, changed func(flag string) b {"pcap", func(d *config.Config, s config.Config) { d.PCAPFile = s.PCAPFile }}, {"pcap-timing", func(d *config.Config, s config.Config) { d.PCAPTiming = s.PCAPTiming }}, {"pcap-loop", func(d *config.Config, s config.Config) { d.PCAPLoop = s.PCAPLoop }}, + {"pcap-memory", func(d *config.Config, s config.Config) { d.PCAPMemory = s.PCAPMemory }}, } for _, f := range fields { if changed(f.flag) { diff --git a/internal/prefs/prefs_test.go b/internal/prefs/prefs_test.go index 318f3ba..4d94e01 100644 --- a/internal/prefs/prefs_test.go +++ b/internal/prefs/prefs_test.go @@ -292,6 +292,7 @@ func TestEveryFieldRoundTrips(t *testing.T) { Duration: 90 * time.Second, PPS: 123456, BPS: 7e9, Queues: 3, RxMode: config.RxUDPPort, RxPorts: []uint16{53, 5353}, RxCIDR: "10.0.0.0/8", PCAPFile: "/tmp/x.pcap", PCAPTiming: config.PcapOriginal, PCAPLoop: false, + PCAPMemory: 4 << 30, } if err := s.Save(&c); err != nil { t.Fatal(err) @@ -313,7 +314,7 @@ func TestEveryFieldRoundTrips(t *testing.T) { got.PPS != want.PPS || got.BPS != want.BPS || got.Queues != want.Queues || got.RxMode != want.RxMode || got.RxCIDR != want.RxCIDR || got.PCAPFile != want.PCAPFile || got.PCAPTiming != want.PCAPTiming || - got.PCAPLoop != want.PCAPLoop { + got.PCAPLoop != want.PCAPLoop || got.PCAPMemory != want.PCAPMemory { t.Errorf("a field was lost in the round trip:\n got %+v\nwant %+v", got, want) } if len(got.RxPorts) != 2 || got.RxPorts[0] != 53 || got.RxPorts[1] != 5353 { diff --git a/internal/tui/fields.go b/internal/tui/fields.go index d4dde40..b49716c 100644 --- a/internal/tui/fields.go +++ b/internal/tui/fields.go @@ -7,6 +7,7 @@ import ( "time" "github.com/atoonk/wireblast/internal/config" + "github.com/atoonk/wireblast/internal/pcapfile" ) // fieldKind decides how a field is edited. @@ -189,6 +190,28 @@ var allFields = []field{ get: func(c *config.Config) string { return yesNo(c.PCAPLoop) }, set: func(c *config.Config, v string) error { c.PCAPLoop = v == "yes"; return nil }, }, + { + key: "pcap-memory", label: "Memory budget", + help: "RAM the capture may use when loading, e.g. 512M or 4G", + show: func(c *config.Config) bool { return c.Mode == config.ModePCAP }, + get: func(c *config.Config) string { + if c.PCAPMemory == 0 { + return config.FormatSize(pcapfile.DefaultMaxBytes) + } + return config.FormatSize(c.PCAPMemory) + }, + set: func(c *config.Config, v string) error { + n, err := config.ParseSize(v) + if err != nil { + return fmt.Errorf("must be a size like 512M or 4G") + } + if n < 1<<20 { + return fmt.Errorf("must be at least 1M") + } + c.PCAPMemory = n + return nil + }, + }, { key: "vlan", label: "VLAN ID", help: "0 sends untagged frames; 1-4094 adds an 802.1Q tag", // The tag is something Wireblast writes, so it means nothing to a run