476 lines
24 KiB
Markdown
476 lines
24 KiB
Markdown
# Streaming `.pcap.zst` Format
|
||
|
||
**Status:** implemented (Rust reference: `pcapstream-core`, `pcapstream-server`, `pcapstream-client`)
|
||
**Audience:** an implementer writing a C++ reader that streams pcap records out of a `.pcap.zst`
|
||
file *while it is still being written*.
|
||
|
||
---
|
||
|
||
## 1. What this format is and why it exists
|
||
|
||
A pcap-stream `.pcap.zst` is a **plain concatenation of independent zstd frames**:
|
||
|
||
```text
|
||
[skippable marker frame] identity + version (carries no pcap bytes)
|
||
[zstd frame] compress(24-byte pcap global header)
|
||
[zstd frame] compress(record-aligned chunk of pcap records) \
|
||
[zstd frame] compress(record-aligned chunk of pcap records) | repeats
|
||
[zstd frame] ... /
|
||
```
|
||
|
||
Two properties fall out of that, and both are load-bearing:
|
||
|
||
1. **It is a valid `.zst` file.** `zstd -d file.pcap.zst` concatenates the frame outputs and
|
||
reproduces the original `.pcap` byte for byte. The marker is a zstd *skippable* frame, so
|
||
compliant decoders ignore it. Nothing about this format requires a special tool to read
|
||
offline.
|
||
2. **It is streamable and seekable at frame granularity.** Every data frame is cut on a pcap
|
||
*record boundary* and declares its decompressed size in its header. A reader can therefore
|
||
walk the file frame by frame, know exactly how many uncompressed pcap bytes each frame
|
||
contributes, and decompress any single frame independently — without decompressing anything
|
||
before it.
|
||
|
||
The point of the format is that a file written this way can be tailed as it grows and served (or
|
||
relayed) **without recompression**: each stored frame is already exactly the payload you'd put on
|
||
the wire.
|
||
|
||
### What the marker frame buys us
|
||
|
||
The marker is the only thing that distinguishes *our* file from an arbitrary `zstd some.pcap`
|
||
produced by the CLI. Both have the extension `.pcap.zst`; both decode to the same pcap. But a
|
||
CLI-produced file is typically **one enormous frame** whose boundaries have nothing to do with
|
||
pcap records, so none of the streaming properties hold. A reader that assumed frame boundaries
|
||
were record-aligned would hand out torn records.
|
||
|
||
So: **a file without a valid leading marker frame must be rejected**, not "best-efforted".
|
||
|
||
---
|
||
|
||
## 2. The marker frame (byte level)
|
||
|
||
A zstd skippable frame is `magic (4 LE) | payload_size (4 LE) | payload`. Skippable magics occupy
|
||
the range `0x184D2A50 .. 0x184D2A5F`; we write the base value.
|
||
|
||
| Offset | Size | Value | Meaning |
|
||
|---|---|---|---|
|
||
| 0 | 4 | `0x184D2A50` (LE) | zstd skippable-frame magic |
|
||
| 4 | 4 | `6` (LE) | payload size |
|
||
| 8 | 4 | `"PSZ1"` (`0x50 0x53 0x5A 0x31`) | pcap-stream zst marker magic |
|
||
| 12 | 1 | `1` | format version |
|
||
| 13 | 1 | `0` | flags (reserved; must be written as 0) |
|
||
|
||
Total: **14 bytes**, always at physical offset 0.
|
||
|
||
Hex, in full:
|
||
|
||
```
|
||
50 2A 4D 18 06 00 00 00 50 53 5A 31 01 00
|
||
```
|
||
|
||
Reader rules:
|
||
|
||
* The **first frame in the file must be a skippable frame whose payload is a valid marker**
|
||
(magic `PSZ1`, version `1`). Anything else → reject the file (`NotMarked`).
|
||
* Validation reads only the first 4 payload bytes (magic) and the version byte. **Ignore the
|
||
flags byte and any trailing payload bytes** — a future version may extend the payload, and the
|
||
`payload_size` field lets you skip whatever you don't understand.
|
||
* An unknown *version* is a hard reject, not a warning. Version is bumped only on an
|
||
incompatible container change.
|
||
* Skippable frames may in principle appear elsewhere in the file. Treat any skippable frame as
|
||
contributing **zero source bytes** and step over it. (Today only the leading marker exists.)
|
||
|
||
|
||
---
|
||
|
||
## 3. Frame invariants a reader may rely on
|
||
|
||
These are guaranteed by every writer of this format. A file that violates them is malformed and
|
||
should be rejected rather than reinterpreted.
|
||
|
||
1. **Frame 0 (physical offset 0) is the marker.** See above.
|
||
2. **Frame 1 is the header frame:** it decompresses to exactly the **24-byte pcap global header**,
|
||
and it covers source range `[0, 24)`. If its declared content size is not 24, the file is
|
||
malformed. Parse it as a classic pcap global header (any of the four magics — LE/BE ×
|
||
µs/ns; pcapng is rejected upstream).
|
||
3. **Every subsequent frame is a data frame** whose decompressed bytes are a whole number of
|
||
complete pcap records — never a partial record header, never a partial payload. Concatenating
|
||
all data frames' outputs yields the pcap body (everything after byte 24).
|
||
4. **Every frame declares its decompressed content size** in its zstd frame header (the writer
|
||
compresses with the input size known, so the field is always present). A frame with an
|
||
undeclared content size is malformed. *This is what lets a reader map physical → source
|
||
offsets without decompressing anything.*
|
||
5. **Every frame carries a zstd content checksum.** Decompression verifies it, so single-bit
|
||
corruption surfaces as a decode error rather than as corrupt packets.
|
||
6. **Frames are contiguous in source coordinates.** Frame *n*'s source range starts exactly where
|
||
frame *n−1*'s ended. There are no gaps and no overlaps.
|
||
7. **Frames are append-only and immutable once written.** Bytes already on disk are never
|
||
rewritten in place. The only exception is truncation of a torn trailing frame at writer
|
||
restart (§7), which happens before any new bytes are appended and always cuts at a frame
|
||
boundary.
|
||
8. Data frames target **~256 KiB of uncompressed source bytes** (`frame_target_bytes`, default
|
||
262144) but may be smaller: a flush timer (default 100 ms) cuts a short frame to bound latency,
|
||
and the last frame of a file is whatever is left. **Do not assume any frame size.** Size the
|
||
read window generously (1 MiB is comfortable) and grow it if a single frame ever exceeds it.
|
||
|
||
Note that packet counts are **not** carried in the container. If you need them, count records
|
||
while decompressing.
|
||
|
||
---
|
||
|
||
## 4. Two coordinate systems
|
||
|
||
Keep these strictly separate; conflating them is the single easiest way to get this wrong.
|
||
|
||
* **Source offset** — a byte offset into the *decompressed* pcap. The global header occupies
|
||
`[0, 24)`; the first data frame starts at 24. This is the coordinate space of the uncompressed pcap.
|
||
* **Physical offset** — a byte offset into the `.pcap.zst` on disk. Includes the 14-byte marker
|
||
and all compressed frame bytes.
|
||
|
||
A frame table entry is therefore four numbers:
|
||
|
||
```cpp
|
||
struct ZstFrame {
|
||
uint64_t src_start; // source (uncompressed pcap) offset of first byte
|
||
uint64_t src_end; // exclusive; src_end - src_start == declared content size
|
||
uint64_t phys_start; // physical offset of the compressed frame
|
||
uint64_t phys_len; // compressed frame size on disk
|
||
};
|
||
```
|
||
|
||
`src_end - src_start` comes from `ZSTD_getFrameContentSize`; `phys_len` comes from
|
||
`ZSTD_findFrameCompressedSize`. Neither requires decompressing the frame — which is the whole
|
||
trick: you can build the frame table for a multi-GB file by reading only frame headers, and you
|
||
only decompress the frames a consumer actually asks for.
|
||
|
||
The marker frame (and any future skippable frame) advances `phys` and **not** `src`.
|
||
|
||
|
||
---
|
||
|
||
## 5. Walking the frames
|
||
|
||
### 5.1 The two zstd calls you need
|
||
|
||
| Purpose | libzstd (C) |
|
||
|---|---|
|
||
| Skippable magic test | `(magic & ZSTD_MAGIC_SKIPPABLE_MASK) == ZSTD_MAGIC_SKIPPABLE_START` |
|
||
| Compressed size of the frame at `p` | `ZSTD_findFrameCompressedSize(p, n)` |
|
||
| Declared decompressed size | `ZSTD_getFrameContentSize(p, n)` |
|
||
| Decompress one frame | `ZSTD_decompress(dst, dstCap, p, csize)` |
|
||
|
||
Both size calls are **header-only** reads. Important subtleties:
|
||
|
||
* `ZSTD_findFrameCompressedSize` returns an error code (test with `ZSTD_isError`) when the buffer
|
||
doesn't contain the whole frame — but it returns the *same* kind of error for a genuinely
|
||
corrupt frame. **You cannot distinguish "need more bytes" from "corrupt" by the error alone.**
|
||
Disambiguate with position: if you have read to end-of-file, treat it as a torn trailing frame
|
||
(stop, retry later); if not, grow your window and retry. This is exactly what the reference
|
||
implementation does.
|
||
* `ZSTD_findFrameCompressedSize` may also return a size **larger than the buffer you passed**.
|
||
Guard with `csize <= n` and treat a larger value as "need more bytes".
|
||
* `ZSTD_getFrameContentSize` returns `ZSTD_CONTENTSIZE_UNKNOWN` or `ZSTD_CONTENTSIZE_ERROR` as
|
||
sentinels. For this format, either is **malformed** (invariant 4) — do not fall back to
|
||
decompressing to learn the size.
|
||
* Skippable frames have no content size. Compute their length yourself: `8 + payload_size`, where
|
||
`payload_size` is the LE `uint32` at offset 4.
|
||
|
||
### 5.2 The walk
|
||
|
||
State: `phys_pos` (next physical byte to parse — always a frame boundary), `src_pos` (next source
|
||
offset — the source frontier), plus `saw_marker`, `header`, `file_id`.
|
||
|
||
```
|
||
loop until phys_pos == file_size (fstat each poll; the file grows under you):
|
||
read a window at phys_pos (1 MiB; short reads are normal — retry/accumulate)
|
||
at_eof := (bytes_read == file_size - phys_pos)
|
||
|
||
if window starts with a skippable magic:
|
||
if the full skippable frame isn't in the window:
|
||
if !at_eof: grow window, retry
|
||
else: stop (torn trailing skippable) — retry on a later poll
|
||
if phys_pos == 0: it must be a valid marker, else reject NotMarked
|
||
phys_pos += 8 + payload_size # src_pos unchanged
|
||
continue
|
||
|
||
if !saw_marker: reject NotMarked # a data frame before any marker
|
||
|
||
csize := ZSTD_findFrameCompressedSize(window)
|
||
if error or csize > bytes_read:
|
||
if at_eof: stop (torn trailing frame) — retry on a later poll
|
||
else: grow window, retry
|
||
|
||
content := ZSTD_getFrameContentSize(window[0..csize])
|
||
if unknown or error: reject Malformed
|
||
|
||
if header not yet seen: # this is the header frame
|
||
if content != 24: reject Malformed
|
||
header := parse_pcap_global_header(decompress(window[0..csize]))
|
||
src_pos += 24 # -> 24
|
||
phys_pos += csize
|
||
continue
|
||
|
||
emit ZstFrame{ src_start: src_pos, src_end: src_pos + content,
|
||
phys_start: phys_pos, phys_len: csize }
|
||
src_pos += content
|
||
phys_pos += csize
|
||
```
|
||
|
||
Key points:
|
||
|
||
* **A torn trailing frame is normal, not an error.** While the writer is mid-append you will
|
||
routinely see a partial frame at EOF. Stop the walk *before* it, leave `phys_pos` pointing at
|
||
its start, and pick it up on a later poll. Never surface a frame you haven't fully read.
|
||
* **`phys_pos` only ever advances across complete frames**, so it is always a valid resume point.
|
||
* Cap the work per poll (the reference consumes ~8 MiB of physical bytes per call, rounded up to
|
||
the frame boundary crossed) so that catching up on a large backlog doesn't stall your event
|
||
loop.
|
||
* Everything above is `pread`-based on a **held-open fd** — see §7.3 for why that matters.
|
||
|
||
To read the packets for a frame: `pread` `phys_len` bytes at `phys_start`, `ZSTD_decompress` into
|
||
a buffer of `src_end - src_start` bytes, and you have a run of complete pcap records. To relay the
|
||
frame to a downstream zstd-speaking consumer: send those `phys_len` bytes **verbatim**. No
|
||
decompress, no recompress.
|
||
|
||
---
|
||
|
||
## 6. File identity (`FileId`)
|
||
|
||
Filenames are not unique — a capture restart can recreate the same name with different content —
|
||
so files are identified by content, not by path.
|
||
|
||
```
|
||
FileId = { first_packet_ts : u64, // nanoseconds
|
||
header_crc32 : u32 }
|
||
```
|
||
|
||
* `header_crc32` = **CRC-32 (IEEE, the zlib/`crc32fast` polynomial, init 0xFFFFFFFF, reflected,
|
||
final xor)** over the 40 bytes `global_header[0..24] || first_record_header[0..16]` — i.e. over
|
||
the *decompressed* source bytes `[0, 40)`.
|
||
* `first_packet_ts` = the first record's timestamp normalized to nanoseconds: `ts_sec * 1e9 +
|
||
ts_frac * 1000` for a µs-resolution file (magic `0xA1B2C3D4` / `0xD4C3B2A1`), or `ts_sec * 1e9 +
|
||
ts_frac` for a ns-resolution file (magic `0xA1B23C4D` / `0x4D3CB2A1`). Fields are read in the
|
||
file's endianness.
|
||
* All-zeros is the **provisional** id: the file exists and has a header but no first packet yet.
|
||
Recompute once the first data frame lands.
|
||
|
||
For a `.pcap.zst`, this means: decompress the header frame (24 bytes) and just enough of the
|
||
**first data frame** to get its first 16 bytes, concatenate, CRC.
|
||
|
||
|
||
---
|
||
|
||
## 7. Detecting completion: the `.partial` rename
|
||
|
||
**This is the part that took the most iteration. Read this section carefully — the wrong approach
|
||
looks like it works and then loses data or reports the wrong file size in production.**
|
||
|
||
### 7.1 The scheme
|
||
|
||
A file being written exists on disk under a name with an in-progress suffix, and is **atomically
|
||
renamed** to its final name when — and only when — it is complete:
|
||
|
||
```
|
||
20260714/cap-20260714-14:30:00.pcap.zst.partial ← still growing
|
||
│
|
||
│ rename(2) — atomic, same directory
|
||
▼
|
||
20260714/cap-20260714-14:30:00.pcap.zst ← complete, immutable
|
||
```
|
||
|
||
The suffix is configurable (`partial_suffix`, default `.partial`) and matches SolarCapture's own
|
||
convention. The four physical forms a reader must recognize are:
|
||
|
||
```
|
||
<name>.pcap <name>.pcap.partial
|
||
<name>.pcap.zst <name>.pcap.zst.partial
|
||
```
|
||
|
||
Strip the partial suffix **first**, then a `.zst` suffix, to recover the **logical name**
|
||
(`<name>.pcap`). The logical name is what appears in the protocol, in resume tokens, and in your
|
||
own bookkeeping — it is *stable across the rename*, which is the whole point. Two flags fall out
|
||
of the stripping: `partial` (is it still growing?) and `zst` (is it compressed?).
|
||
|
||
|
||
### 7.2 The rule
|
||
|
||
> **The rename is the completion signal. Nothing else is.**
|
||
|
||
Concretely, a reader declares a file complete when **both** of these hold:
|
||
|
||
1. **The rename has been observed** — the final path exists.
|
||
2. **The file is fully drained** — you have walked every byte of it: `phys_pos == file_size`
|
||
(fresh `fstat`), i.e. no torn trailing frame remains.
|
||
|
||
Only then do you know the final size, which for a `.pcap.zst` is the **source frontier**
|
||
(`src_pos`), *not* the physical file size. (For a raw `.pcap` the two coincide, which is a trap if
|
||
you generalize from the raw path.)
|
||
|
||
If (1) holds but (2) doesn't — the rename landed but there's still an unframed tail — **wait**.
|
||
Do not declare completion with a size you'd have to revise. Poll again; the tail is already fully
|
||
written, so this resolves on the next tick.
|
||
|
||
### 7.3 What NOT to do (each of these was tried and removed)
|
||
|
||
* ❌ **Do not use a quiet period / size-stability heuristic** ("no growth for N seconds ⇒ done").
|
||
A capture stall is indistinguishable from a finished file, and it fires early under load. This
|
||
was ripped out in favor of the rename signal alone.
|
||
* ❌ **Do not treat the appearance of the *next* file as the completion signal for the current
|
||
one.** Same commit, same reason: it couples two files' lifecycles and it's wrong at a capture
|
||
restart.
|
||
* ❌ **Do not re-open the file by path when you notice the rename.** The reader must keep serving
|
||
from the **fd it already holds**. A rename does not invalidate an open fd — it still points at
|
||
the same inode, and reads continue to work through the rename *and even through an unlink*. If
|
||
you close and reopen, you introduce a window where the `.partial` path is gone and the reader
|
||
ENOENTs, and you break continuity for any consumer mid-file.
|
||
* ❌ **Do not assume the on-disk file size is the source size for a `.zst`.** It's the compressed
|
||
size. Track the source frontier separately.
|
||
* ❌ **Do not re-scan and re-register the renamed file as a new file.** A directory scan after the
|
||
rename sees a *new* directory entry (the final name). Because the logical name is
|
||
suffix-stripped, it maps to the file you already know — dedupe on the **logical name**, not the
|
||
physical path.
|
||
|
||
### 7.4 The one subtlety that bites: the ENOENT window
|
||
|
||
There is a gap between "the rename happened" and "we have finished draining and finalized the
|
||
file". That gap can be seconds (framing the tail, serving a backlog). During that gap:
|
||
|
||
* the `.partial` path **no longer exists** (rename is atomic — the old name is gone the instant
|
||
the new one appears), but
|
||
* your in-memory state may still be pointing at the `.partial` path for any *new* open.
|
||
|
||
Anything that opens the file by path in that window — a lazily-rebuilt frame, a new consumer
|
||
attaching, a cache miss — gets **ENOENT**, on a file that is perfectly healthy.
|
||
|
||
The fix (commit `4c5a0e8`) is: **the moment you observe the rename, repoint your stored serve
|
||
path at the final path**, immediately — long before you finalize the file. Keep draining through
|
||
the fd you already hold (which is unaffected), but make sure any *new* open uses the final name.
|
||
|
||
```
|
||
on scan tick, for each growing file:
|
||
if is_partial && !complete_signal && exists(final_path):
|
||
complete_signal = true
|
||
serve_path = final_path # ← do this NOW, not at finalization
|
||
# keep tailing via the already-open fd; finalize once drained (§7.2)
|
||
```
|
||
|
||
That ordering — signal and repoint first, drain second, finalize third — is the whole rename
|
||
protocol. Get it wrong and you get intermittent ENOENTs under exactly the conditions that are
|
||
hardest to reproduce (a consumer reconnecting during the rename).
|
||
|
||
### 7.5 Detecting the rename
|
||
|
||
Poll: on each scan, for every file you are tailing that is still `partial`, `stat` its final path.
|
||
Existence of the final path = the rename happened. This is cheap (one `stat` per growing file, and
|
||
there is normally exactly one) and needs no inotify. The reference implementation drives this on a
|
||
1 s timer; a caller-driven scan loop can poll as often as it likes.
|
||
|
||
If you prefer inotify, watch for `IN_MOVED_TO` in the capture directory — but keep the `stat`
|
||
fallback: inotify queues overflow, and missing a rename means never completing a file.
|
||
|
||
### 7.6 Files discovered already-final
|
||
|
||
A file discovered with **no** partial suffix (final name, no `.partial` sibling) is **born
|
||
complete** — it finished before you ever saw it. Register it as complete immediately. Don't wait
|
||
for a rename that already happened.
|
||
|
||
A reader that builds a frame table (§4) to serve random-access requests should build it lazily,
|
||
the first time a consumer actually asks for the file — walking/framing every already-final file at
|
||
discovery reads every byte of every file in a directory of a day's captures for nothing. A reader
|
||
that just streams packets out sequentially (walking §5.2 once, start to finish, with no frame
|
||
table) doesn't have this concern either way.
|
||
|
||
### 7.7 Writer restart and truncation (reader's perspective)
|
||
|
||
You never write these files, but the process that does can restart mid-file. On restart it walks
|
||
its own `.partial`, and truncates it back to **a frame boundary** — never mid-frame — before
|
||
resuming appends.
|
||
|
||
The guarantee that matters to a reader: **content at a given source offset never changes.** A
|
||
restart can only shrink the file back to an earlier frame boundary and re-append from there; the
|
||
capture data being re-appended is the same data, so whatever lands at a given source offset is
|
||
byte-for-byte identical to what would have been there without the restart. Only the physical
|
||
framing (where frame boundaries fall) can differ across a restart — never the decompressed
|
||
content.
|
||
|
||
Two cases follow from that:
|
||
|
||
* **Ordinary case — the truncated bytes were never surfaced.** A writer only ever truncates a
|
||
*torn* trailing frame (invariant 7 in §3), and a reader following §5.2 never surfaces a torn
|
||
trailing frame in the first place — it stops before it and waits. So `phys_pos` already sits at
|
||
or before the truncation point. There is nothing to undo: the next poll simply finds the file
|
||
has grown again (with corrected content) from the same boundary you were already parked at, and
|
||
the walk continues exactly as if nothing happened.
|
||
* **Defensive case — the file shrinks below `phys_pos`.** Check this on every poll with a fresh
|
||
`fstat`, the same as you already do to detect EOF: if `file_size < phys_pos`, trust the frames
|
||
you have already read — they decoded and checksummed cleanly, so they're not in question — and
|
||
simply stop advancing. Wait, polling as usual, until `file_size` grows back past `phys_pos`
|
||
again, then resume the walk from `phys_pos` exactly as before. Because content per source offset
|
||
is guaranteed stable, whatever now occupies the bytes at and beyond `phys_pos` is the correct
|
||
continuation, so no rewind or re-parse of already-consumed frames is needed.
|
||
|
||
---
|
||
|
||
## 8. Error handling
|
||
|
||
| Condition | Action |
|
||
|---|---|
|
||
| First frame is not a valid marker | **Reject** the file (`NotMarked`). It's a foreign `.zst` whose frames don't align to records. Log the path; do not attempt to stream it. |
|
||
| Marker version unknown | **Reject.** |
|
||
| Header frame's content size ≠ 24 | **Reject** (`Malformed`). |
|
||
| Any frame with undeclared/error content size | **Reject** (`Malformed`). |
|
||
| Torn frame at EOF | **Not an error.** Stop before it; retry on the next poll. |
|
||
| zstd decode error / checksum failure | **Reject and alarm.** Never resync by scanning forward for a plausible frame magic — you'd hand out garbage records. Stop tailing this file. |
|
||
| `incl_len` > 256 KiB (`MAX_INCL_LEN`) or > snaplen | Treat as corruption; stop and alarm, don't resync. (Relevant when validating decompressed records.) |
|
||
|
||
The consistent principle: **fail loudly, never resync.** A file that violates an invariant is
|
||
either corrupt or not ours, and silently guessing at frame boundaries is worse than stopping.
|
||
|
||
---
|
||
|
||
## 9. Constants
|
||
|
||
| Name | Value |
|
||
|---|---|
|
||
| `SKIPPABLE_MAGIC` | `0x184D2A50` (range `0x184D2A50..=0x184D2A5F`) |
|
||
| `MARKER_MAGIC` | `"PSZ1"` |
|
||
| `MARKER_VERSION` | `1` |
|
||
| Marker frame length | 14 bytes |
|
||
| `GLOBAL_HEADER_LEN` | 24 |
|
||
| `RECORD_HEADER_LEN` | 16 |
|
||
| `MAX_INCL_LEN` | 262144 (256 KiB) |
|
||
| `frame_target_bytes` (default) | 262144 (~256 KiB of *source* bytes) |
|
||
| `frame_flush_ms` (default) | 100 (may cut a short frame) |
|
||
| `zstd_level` (default) | 2 (writer-side; irrelevant to a reader) |
|
||
| `partial_suffix` (default) | `.partial` |
|
||
| Read window | 1 MiB, grown (×2) if a single frame exceeds it |
|
||
| Scan interval (reference default; caller-driven elsewhere) | 1000 ms |
|
||
| Poll interval (reference default; caller-driven elsewhere) | 20 ms |
|
||
|
||
---
|
||
|
||
## 10. Conformance checklist
|
||
|
||
A C++ reader is done when it passes these.
|
||
|
||
- [ ] Walks a marked file (marker + header frame + N data frames) and produces a frame table whose
|
||
first entry starts at source offset 24 and whose entries are contiguous.
|
||
- [ ] Rejects an unmarked file — e.g. one produced by plain `zstd file.pcap` — with a distinct
|
||
"not our format" error.
|
||
- [ ] Rejects a marker with a bad version byte.
|
||
- [ ] Handles a truncated marker (fewer than 14 bytes on disk) as "need more", not as an error.
|
||
- [ ] Tails a growing file: a frame appended mid-poll is surfaced on a subsequent poll, and a torn
|
||
trailing frame is *not* surfaced until complete.
|
||
- [ ] Whole-file `zstd -d` output equals the source `.pcap` byte for byte.
|
||
- [ ] Decompressing frame *n* alone yields exactly `src_end - src_start` bytes, and those bytes are
|
||
a whole number of pcap records.
|
||
- [ ] A corrupted byte inside a frame surfaces as a decode error (checksum), not as packets.
|
||
- [ ] `FileId` computed from a `.pcap.zst` equals the `FileId` computed from the same capture
|
||
stored as a raw `.pcap`.
|
||
- [ ] **Rename:** a `.partial` renamed mid-stream is detected; the file is finalized with the
|
||
**source** size (not the compressed size); a consumer attached across the rename sees no
|
||
interruption and no ENOENT; a new consumer attaching *during* the drain window opens the
|
||
final path successfully.
|
||
- [ ] A file discovered already-final (no `.partial`) is treated as complete without waiting for a
|
||
rename.
|
||
|