gtbundle

package
v0.7.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package gtbundle reads and writes the GopherTrunk Bundle — a single portable .gtb.tar.gz archive that packages one SDR capture-to-analysis "case": the raw IQ capture(s), a narrowband slice, the session logs, the SigLab signal-analysis result, the CryptoLab crypto-assessment result, and the hunt site/network mapping, all tied together by a human-and-machine readable MANIFEST.yaml.

One bundle carries a whole investigation so it can be shared with the community as a single file and flow seamlessly capture → SigLab → CryptoLab → back into the scanner config.

Disambiguation: the word "bundle" is overloaded in this repo. The hunt package's FormatBundle (internal/hunt/export.go) is a multi-section CSV that round-trips a discovered system into config.yaml — the "hunt CSV import bundle". That CSV is *stored inside* a GopherTrunk Bundle (as mapping/system.bundle.csv, role mapping-export); it is not this archive. This package is always spelled "GopherTrunk Bundle" / .gtb, and its Go identifier is gtbundle, never bundle.

On-disk layout, under a single top-level directory named after the bundle:

<name>/
  MANIFEST.yaml          index: schema_version, provenance, file inventory
  README.md              auto-generated human summary
  notes.md               free-form operator notes (optional)
  capture/               raw IQ (.cfile), narrowband slice (.wav), meta (.yaml)
  logs/                  events.jsonl (all bus events), messages.log (human)
  mapping/               system.json (DiscoveredSystem), survey.jsonl, exports
  siglab/                result.yaml (siglab.Result), events.jsonl
  cryptolab/             frames.jsonl, assess.result.yaml (cryptolab.Result)

Serialization follows the repo conventions: YAML (snake_case) for the manifest and analysis summaries whose models carry yaml tags; JSON for hunt.DiscoveredSystem (which has only json tags); JSONL for streams; raw binary for IQ. Frequencies are integer Hz, timestamps UTC RFC3339.

Index

Constants

View Source
const (
	// SchemaVersion is the on-disk manifest schema version. Readers accept any
	// bundle whose schema_version is <= this value; a higher version is read
	// best-effort with a warning rather than rejected (see reader.go).
	SchemaVersion = 1
	// FormatID identifies the archive family; it is written into the manifest's
	// `format` field so a consumer can tell a GopherTrunk Bundle from any other
	// tar.gz at a glance.
	FormatID = "gophertrunk-bundle"
	// ManifestName / ReadmeName are the fixed filenames at the bundle root.
	ManifestName = "MANIFEST.yaml"
	ReadmeName   = "README.md"
	// Ext is the conventional archive extension.
	Ext = ".gtb.tar.gz"
)

Variables

View Source
var ErrChecksum = errors.New("gtbundle: sha256 mismatch")

ErrChecksum is returned when a file's bytes do not match the sha256 recorded in the manifest.

View Source
var ErrFutureSchema = errors.New("gtbundle: manifest schema newer than supported")

ErrFutureSchema is returned (wrapped, non-fatal) by ParseManifest/Open when a bundle's schema_version is newer than this build supports. The bundle is still usable best-effort.

View Source
var ErrNotFound = errors.New("gtbundle: not found")

ErrNotFound is returned when a requested role/path is absent.

Functions

func MarshalManifest

func MarshalManifest(m *Manifest) ([]byte, error)

MarshalManifest renders m as YAML with two-space indentation, matching the repo's siglab/cryptolab YAML export style.

func MetadataToSigMF

func MetadataToSigMF(m *siglab.Metadata) ([]byte, bool, error)

MetadataToSigMF renders a siglab.Metadata as a SigMF .sigmf-meta document. It returns ok=false when the capture format has no SigMF datatype equivalent, so the caller can skip emitting a sidecar rather than write a malformed one.

func SanitizeName

func SanitizeName(s string) string

SanitizeName reduces a proposed bundle name to the safe token used as the single top-level directory inside the archive. It never returns "" (falls back to "bundle") and strips any leading dots/dashes so the name can't hide or look like a flag.

Types

type CaptureIntent

type CaptureIntent string

CaptureIntent classifies why a case was collected. It documents the operator's purpose and drives the default narrowband-slice length when a bundle is produced from a live capture.

const (
	IntentCCMap   CaptureIntent = "cc-map"  // control-channel mapping / hunt
	IntentCrypto  CaptureIntent = "crypto"  // encryption assessment
	IntentSurvey  CaptureIntent = "survey"  // wideband classification survey
	IntentGeneral CaptureIntent = "general" // unspecified / general purpose
)

func (CaptureIntent) Valid

func (i CaptureIntent) Valid() bool

Valid reports whether i is a recognized intent.

type FileEntry

type FileEntry struct {
	Path       string `yaml:"path"`
	Role       Role   `yaml:"role"`
	MediaType  string `yaml:"media_type"`
	SizeBytes  int64  `yaml:"size_bytes"`
	SHA256     string `yaml:"sha256"`
	ProducedBy string `yaml:"produced_by,omitempty"`
	Note       string `yaml:"note,omitempty"`
}

FileEntry is one archived artifact. Path is bundle-relative (including the top-level dir) and always uses forward slashes, e.g. "mesa-p25/capture/cc.cfile". SHA256 is lowercase hex over the file bytes.

type Generator

type Generator struct {
	Tool    string `yaml:"tool"`
	Version string `yaml:"version"`
	Commit  string `yaml:"commit,omitempty"`
}

Generator records the tool that produced the bundle.

type Manifest

type Manifest struct {
	SchemaVersion int           `yaml:"schema_version"`
	Format        string        `yaml:"format"`
	Name          string        `yaml:"name"`
	CaptureIntent CaptureIntent `yaml:"capture_intent"`
	CreatedAt     time.Time     `yaml:"created_at"`

	Generator  Generator  `yaml:"generator"`
	Provenance Provenance `yaml:"provenance"`

	Title string `yaml:"title,omitempty"`
	Notes string `yaml:"notes,omitempty"`

	Files []FileEntry `yaml:"files"`
}

Manifest is the machine-and-human index stored at <name>/MANIFEST.yaml. It is YAML with snake_case keys. Unknown top-level keys and unknown file roles are tolerated on read so a newer bundle still opens in an older binary.

func ParseManifest

func ParseManifest(raw []byte) (*Manifest, error)

ParseManifest decodes a MANIFEST.yaml. It validates the format id and that the schema version is not from the future beyond what this build supports; an unsupported-but-higher version is returned with a non-nil *Manifest and a non-fatal ErrFutureSchema so the caller can warn and proceed.

func (*Manifest) Find

func (m *Manifest) Find(r Role) (FileEntry, bool)

Find returns the first file entry with the given role, or ok=false.

func (*Manifest) List

func (m *Manifest) List(r Role) []FileEntry

List returns every file entry with the given role, in manifest order.

type Provenance

type Provenance struct {
	Source       string    `yaml:"source,omitempty"`
	Operator     string    `yaml:"operator,omitempty"`
	CenterFreqHz uint32    `yaml:"center_freq_hz,omitempty"`
	SampleRateHz float64   `yaml:"sample_rate_hz,omitempty"`
	Protocol     string    `yaml:"protocol,omitempty"`
	Location     string    `yaml:"location,omitempty"`
	CapturedAt   time.Time `yaml:"captured_at,omitempty"`
	DeviceDriver string    `yaml:"device_driver,omitempty"`
	DeviceSerial string    `yaml:"device_serial,omitempty"`
}

Provenance is the capture-side context: where, when, and how the RF was collected. Every field is optional — a bundle packed from loose files may know little about the capture — but recording what is known makes a shared case reproducible. Frequencies are integer Hz; timestamps are UTC RFC3339.

type Reader

type Reader struct {
	// contains filtered or unexported fields
}

Reader provides random access to a bundle. Because tar is sequential, the whole archive is read into memory on open; bundles are small (a manifest plus a handful of analysis files and one raw IQ), and the large IQ member can be streamed straight to a working file with ExtractRole/CaptureIQ. A Reader is read-only and safe for concurrent reads after construction.

func NewReader

func NewReader(r io.Reader) (*Reader, error)

NewReader reads a bundle from any stream.

func Open

func Open(path string) (*Reader, error)

Open reads a .gtb.tar.gz from disk.

func (*Reader) Bytes

func (r *Reader) Bytes(role Role) ([]byte, *FileEntry, error)

Bytes returns the verified bytes of the first file with role, or ErrNotFound. The sha256 is checked against the manifest; a mismatch returns ErrChecksum.

func (*Reader) CaptureIQ

func (r *Reader) CaptureIQ() ([]byte, *FileEntry, error)

CaptureIQ returns the verified raw-IQ bytes and its entry. For a large capture prefer ExtractRole(RoleCaptureIQ, dir) to stream it to a working file.

func (*Reader) CaptureMeta

func (r *Reader) CaptureMeta() (*siglab.Metadata, error)

CaptureMeta decodes the capture metadata sidecar (siglab.Metadata, YAML).

func (*Reader) CryptolabFramesRaw

func (r *Reader) CryptolabFramesRaw() ([]byte, error)

CryptolabFramesRaw returns the verified bytes of the cryptolab frames stream (cryptolab/frames.jsonl). Returns ErrNotFound when absent.

func (*Reader) CryptolabResultRaw

func (r *Reader) CryptolabResultRaw() ([]byte, error)

CryptolabResultRaw returns the verified bytes of the cryptolab result (cryptolab/assess.result.yaml). It is exposed as raw YAML rather than a typed accessor so this package needn't import internal/cryptolab; the caller (which already links cryptolab) decodes it. Returns ErrNotFound when absent.

func (*Reader) Extract

func (r *Reader) Extract(dstDir string) error

Extract writes the whole bundle tree under dstDir, verifying each listed file as it goes. Untracked members are extracted too (best-effort). It re-validates every member path against traversal.

func (*Reader) ExtractRole

func (r *Reader) ExtractRole(role Role, dstDir string) ([]string, error)

ExtractRole writes just the files of one role under dstDir (flattened to their leaf names) and returns the written paths. Handy for handing one subtree onward — e.g. the cryptolab frames to a colleague.

func (*Reader) FutureSchema

func (r *Reader) FutureSchema() bool

FutureSchema reports whether the bundle declares a schema newer than this build supports (it was still read best-effort).

func (*Reader) Manifest

func (r *Reader) Manifest() *Manifest

Manifest returns the parsed manifest.

func (*Reader) MappingSurvey

func (r *Reader) MappingSurvey() (*hunt.SignalSurvey, error)

MappingSurvey reconstructs a SignalSurvey from the survey NDJSON stream: one DetectedSignal per line. The System pointer is left nil (it lives in mapping/system.json); callers that need it use MappingSystem.

func (*Reader) MappingSystem

func (r *Reader) MappingSystem() (*hunt.DiscoveredSystem, error)

MappingSystem decodes the discovered system map (hunt.DiscoveredSystem, JSON).

func (*Reader) Open

func (r *Reader) Open(role Role) (io.ReadCloser, *FileEntry, error)

Open returns a reader over the verified bytes of the first file with role.

func (*Reader) SiglabResult

func (r *Reader) SiglabResult() (*siglab.Result, error)

SiglabResult decodes the SigLab analysis result (siglab.Result, YAML).

func (*Reader) Verify

func (r *Reader) Verify() []VerifyResult

Verify checks every manifest file's size+sha256 and flags any archive member not listed in the manifest as Untracked (non-fatal). It returns one result per manifest file plus one per untracked member.

type Role

type Role string

Role names one artifact's meaning inside a bundle. Roles are a closed, append-only vocabulary: new roles may be added but existing ones are never renamed or repurposed, so an old reader still understands an old role in a newer bundle. A role a reader does not recognize is tolerated (see the forward-compat rules in reader.go) — it is preserved verbatim and bucketed under misc/ on a rewrite.

const (
	RoleCaptureIQ    Role = "capture-iq"    // capture/<stem>.cfile — raw IQ, source of truth
	RoleCaptureSlice Role = "capture-slice" // capture/<stem>.slice.wav — narrowband DDC slice
	RoleCaptureMeta  Role = "capture-meta"  // capture/<stem>.meta.yaml — siglab.Metadata
	RoleCaptureSigMF Role = "capture-sigmf" // capture/<stem>.sigmf-meta — optional SigMF sidecar

	RoleLogEvents   Role = "log-events"   // logs/events.jsonl — all bus events (EventLog)
	RoleLogMessages Role = "log-messages" // logs/messages.log — human decoded-message log

	RoleMappingSystem Role = "mapping-system" // mapping/system.json — hunt.DiscoveredSystem
	RoleMappingSurvey Role = "mapping-survey" // mapping/survey.jsonl — hunt.DetectedSignal stream
	RoleMappingExport Role = "mapping-export" // mapping/system.bundle.csv | .trunk-recorder.json | .rr.md

	RoleSiglabResult Role = "siglab-result" // siglab/result.yaml — siglab.Result
	RoleSiglabEvents Role = "siglab-events" // siglab/events.jsonl — siglab events/PDUs

	RoleCryptolabFrames Role = "cryptolab-frames" // cryptolab/frames.jsonl — cryptocap records
	RoleCryptolabResult Role = "cryptolab-result" // cryptolab/assess.result.yaml — cryptolab.Result

	RoleNotes  Role = "notes"  // notes.md — free-form operator notes
	RoleReadme Role = "readme" // README.md — auto-generated summary
)

func (Role) Valid

func (r Role) Valid() bool

Valid reports whether r is a role this build understands.

type VerifyResult

type VerifyResult struct {
	Path      string
	Role      Role
	OK        bool
	Untracked bool // present in archive but not in the manifest
	Err       error
}

VerifyResult is one file's integrity outcome.

type Writer

type Writer struct {
	// contains filtered or unexported fields
}

Writer assembles a .gtb.tar.gz. Files are streamed in with Add/AddFile/AddYAML as they are produced; MANIFEST.yaml and README.md are written by Close, which finalizes the archive. A Writer is not safe for concurrent use.

func NewWriter

func NewWriter(w io.Writer, opts WriterOptions) (*Writer, error)

NewWriter wraps w in gzip+tar and prepares an in-memory manifest. The caller must Close the Writer to flush the archive. w is typically an *os.File.

func (*Writer) Add

func (w *Writer) Add(role Role, leaf string, src io.Reader, producedBy string) (string, error)

Add stages src under the canonical path for role (topDir/roleDir/leaf), computing its sha256 and size and appending a manifest entry. leaf must be a single path component; producedBy records the tool/step that made the file. It returns the bundle-relative path.

func (*Writer) AddBytes

func (w *Writer) AddBytes(role Role, leaf string, body []byte, producedBy string) (string, error)

AddBytes is Add for an in-memory buffer.

func (*Writer) AddJSON

func (w *Writer) AddJSON(role Role, leaf string, v any, producedBy string) (string, error)

AddJSON marshals v as indented JSON and stages it. Use for hunt.DiscoveredSystem (json-only tags) so snake_case is preserved.

func (*Writer) AddYAML

func (w *Writer) AddYAML(role Role, leaf string, v any, producedBy string) (string, error)

AddYAML marshals v as two-space YAML and stages it. Use for models with yaml tags (siglab.Metadata, siglab.Result, cryptolab.Result).

func (*Writer) Close

func (w *Writer) Close() error

Close writes MANIFEST.yaml and README.md, emits every staged file in deterministic role order, and flushes tar+gzip. It is safe to call once; a second call is a no-op.

func (*Writer) Manifest

func (w *Writer) Manifest() *Manifest

Manifest returns the in-progress manifest header (files reflect what has been staged so far). The returned pointer is live; do not mutate Files.

func (*Writer) SetNote

func (w *Writer) SetNote(note string)

SetNote attaches a free-text note to the most recently added file entry (e.g. "signal wider than tune — recorded as a slice"). It is a no-op if nothing has been added yet.

type WriterOptions

type WriterOptions struct {
	Name          string
	CaptureIntent CaptureIntent
	Provenance    Provenance
	Title         string
	Notes         string
	// Now is an injectable clock so tests can produce deterministic archives.
	// When nil, time.Now is used. Its value stamps CreatedAt and every tar
	// header ModTime, making a repack byte-identical for a fixed input set.
	Now func() time.Time
}

WriterOptions configure a new bundle. Name is required; everything else has a sensible default.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL