TunnelVision

Spec — pcap capture writer (Shared/Capture)

Streams captured packets to standard libpcap files on disk so they open directly in Wireshark and tcpdump. Streaming is mandatory: a long capture must never be held in memory (the extension budget forbids it).

Both halves live in ../../Shared/Capture. The format (PcapFormat) moved there in M9: the app parses these files back to show a packet’s bytes, so writing a header and reading it are one truth. The writer (PcapWriter) followed in M11, when the synthetic-capture seeder (../../Shared/Fixtures) had to write .pcap files from the app — and had to write them with this writer, so that the offset stored with a seeded packet is produced by the same code that produces a real one’s.

Format (classic libpcap)

Because the extension reads raw IP packets (no Ethernet), the link-layer type is LINKTYPE_RAW (DLT_RAW, value 101): each record is a bare IP packet.

Global header (24 bytes, written once per file)

Field Size Value
magic_number u32 0xa1b2c3d4 (microsecond ts)
version_major u16 2
version_minor u16 4
thiszone i32 0
sigfigs u32 0
snaplen u32 e.g. 262144
network u32 101 (LINKTYPE_RAW)

Per-packet record header (16 bytes, before each packet)

Field Size Value
ts_sec u32 capture time, seconds
ts_usec u32 microseconds
incl_len u32 bytes captured (≤ snaplen)
orig_len u32 original packet length

Then incl_len bytes of the raw IP packet.

Endianness is fixed by the magic number; write native little-endian on-device and set the standard magic accordingly.

Interface

public actor PcapWriter {
    public struct Config: Sendable {
        public var directory: URL
        public var snaplen: UInt32          // bytes captured per packet
        public var maxFileBytes: UInt64     // rotation threshold
        /// Mayor secuencia que el historial ya referencia (`FlowStore.highestCaptureFileSequence()`).
        public var highestReferencedSequence: UInt32?
    }

    public enum PcapError: Error, Sendable { case openFailed, writeFailed, sequenceExhausted }

    public init(config: Config) throws       // opens the first file, writes global header

    /// Escribe un paquete y devuelve dónde quedó su registro (para `PacketMeta.capture`).
    /// `timestamp` es **nanosegundos desde el epoch** (`MonotonicAnchor.nanosecondsSince1970`).
    @discardableResult
    public func write(packet: Data, originalLength: Int, timestamp: Int64) throws -> CaptureLocation

    /// Cierra el fichero actual y abre uno nuevo (rotación manual o por tamaño).
    public func rotate() throws

    /// Cambia cuánto se guarda de cada paquete, **rotando**: el `snaplen` vive en la cabecera global,
    /// así que el detalle nuevo solo puede empezar en un fichero nuevo. No hace nada si ya es el que hay.
    public func setSnaplen(_ snaplen: UInt32) throws

    /// Fuerza el vaciado a disco (fsync) del fichero actual **sin** cerrarlo: un writer en
    /// streaming necesita seguir escribiendo tras un flush periódico. El cierre definitivo lo
    /// hacen `rotate()` y `close()`.
    public func flush() throws
    public func close()

    /// Ficheros de captura existentes, para exportar/compartir/borrar desde la app.
    public func captureFiles() -> [URL]

    /// Secuencia del fichero que se está escribiendo ahora mismo.
    public var currentFileSequence: UInt32 { get }
}

File identity

Files are named tunnelvision-<sequence>-<yyyyMMdd-HHmmss>.pcap. The sequence is zero-padded to six digits so alphabetical order is chronological order, and it is the file’s identity: it is what CaptureLocation.fileSequence stores next to every packet, and what the app resolves back to a URL. Formatting and parsing it live together in Shared/Models (CaptureFileName, CaptureDirectory) because the extension writes the name and the app reads it.

The sequence does not restart per session. A new writer starts one above the highest it can see — the highest in the directory and the highest the history already references (Config.highestReferencedSequence, from FlowStore.highestCaptureFileSequence()). Restarting at 0 would make yesterday’s stored offset point into today’s file; ignoring the history would let a deleted file’s number be handed to a new file while its packets are still stored. sequenceExhausted models the (unreachable, 4·10⁹ files) end of the UInt32 space instead of wrapping around and lying.

Behaviour

Reading one record back (M9)

The app never reads a capture whole — a 64 MB file must not be loaded to show one packet. PcapFormat therefore parses as well as it writes, and the app’s side of it is one method on CaptureLibrary (app-services.md):

public enum PcapFormat {
    public struct GlobalHeader: Sendable, Equatable { public let snaplen: UInt32; public let linkType: UInt32 }
    public struct RecordHeader: Sendable, Equatable {
        public let tsSec: UInt32; public let tsUsec: UInt32
        public let inclLen: UInt32; public let origLen: UInt32
    }
    public enum FormatError: Error, Sendable, Equatable {
        case shortHeader(expected: Int, actual: Int)
        case unknownMagic(UInt32)
        case unsupportedLinkType(UInt32)
    }
    public static func globalHeader(parsing data: Data) throws -> GlobalHeader
    public static func recordHeader(parsing data: Data) throws -> RecordHeader
}

The reading itself is PcapFileReader (Shared/Capture), since it gained a second caller — the capture of an evidence bundle, which slices these files to one audit session (audit.md § The capture):

public final class PcapFileReader {
    public enum ReadError: Error, Sendable, Equatable {
        case openFailed
        case offsetInsideFileHeader(UInt64)
        case format(PcapFormat.FormatError)
        case recordExceedsSnaplen(inclLen: UInt32, snaplen: UInt32)
        case recordCutShort(expected: UInt32, actual: Int)
        case readFailed(byteCount: Int, offset: UInt64, reason: String)
    }
    public let header: PcapFormat.GlobalHeader
    public init(url: URL) throws                     // reads and validates the global header
    public func record(at offset: UInt64) throws -> PcapRecord
    public func close()
}

One file opened once, any number of records read from it by offset. CaptureLibrary opens one per packet shown and words each failure for its screen; the slicer keeps one open per source file. Whoever opens a reader closes it.

pcapng, for what is exported as evidence

The extension writes classic pcap and nothing here changes that. PcapngFormat exists for the one file that needs options per packet — the capture of an evidence bundle, where each packet carries a comment and its direction — and writes only the three blocks that takes:

public enum PcapngFormat {
    public enum FormatError: Error, Sendable, Equatable {
        case optionTooLong(code: UInt16, byteCount: Int)
        case packetTooLong(byteCount: Int)
    }
    public static func sectionHeader(application: String?) throws -> Data
    public static func interfaceDescription(snaplen: UInt32) throws -> Data
    public static func enhancedPacket(
        timestampMicroseconds: UInt64, originalLength: UInt32, bytes: Data,
        direction: Direction?, comment: String?
    ) throws -> Data
}

It was checked against a reader this project did not write: tcpdump -k IDC -r prints each packet’s instant, in / out and comment.

Export

The app shares capture files via the share sheet as .pcap. Provide a per-flow export too (filter records by flow) and a JSON flow-list export. Exports never leave the device except through the user’s explicit share action.

Tests (M6)