hydra

package module
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 15 Imported by: 0

README

hydra

Pure Go implementation of the HYDRA bidirectional file transfer protocol (FTSC FSC-0072), as used by FidoNet mailers — HydraCom, bforce, FTNd, qico, BinkleyTerm, Argus, Taurus.

This is a library package — there is no CLI. Users import the hydra package, provide a SendHandler and/or RecvHandler (one object may implement both), and drive sessions over any io.ReadWriter transport (TCP sockets, serial ports, PTYs). Zero dependencies outside the Go standard library.

Features

  • Full bidirectional transfer: both sides send and receive concurrently over one byte stream
  • Capability negotiation (HydraCom-compatible union semantics), CRC-16 (poly 0x8408) and CRC-32, all four body encodings decoded (BIN, HEX, ASC, UUE; BIN/HEX emitted)
  • Resume, receiver-driven repositioning (RPOS — emits the de facto 12-byte form, accepts both widths), skip/defer per file
  • Sliding-window flow control (DATAACK) when configured; full streaming by default
  • Adaptive block sizing (256–2048 bytes, HydraCom ladder after errors)
  • Device/chat channel (DEVDATA/DEVDACK) with an optional DeviceHandler
  • Multi-batch sessions: Run completes one START…END cycle and leaves the transport open, matching mailers (bforce) that run several batches per connection
  • Escape-set negotiation for dirty links (XON/TLN/CTL/HIC/HI8), cancel sequence, brain-dead watchdog with reference-exact reset rules

Usage

package main

import (
	"context"
	"io"
	"log"
	"net"
	"os"
	"time"

	"github.com/xx25/go-hydra"
)

// handler implements hydra.SendHandler and hydra.RecvHandler; both
// directions run concurrently through it. Pass nil for a direction you
// don't use: nil send = empty TX batch, nil recv = every incoming file
// deferred with FINFOACK(-2).
type handler struct {
	outbound []*hydra.FileOffer
}

func (h *handler) NextFile() *hydra.FileOffer {
	if len(h.outbound) == 0 {
		return nil
	}
	f := h.outbound[0]
	h.outbound = h.outbound[1:]
	return f
}

func (h *handler) AcceptFile(info hydra.FileInfo) (io.WriteCloser, int64, error) {
	name := hydra.SanitizeFilename(info.Name) // peer names are untrusted
	f, err := os.Create("inbound/" + name)
	return f, 0, err // offset > 0 resumes a partial file
}

func (h *handler) FileProgress(info hydra.FileInfo, n int64) {}

func (h *handler) FileCompleted(info hydra.FileInfo, n int64, err error) {
	log.Printf("%s: %d bytes, err=%v", info.Name, n, err)
}

func main() {
	conn, err := net.Dial("tcp", "peer:24555")
	if err != nil {
		log.Fatal(err)
	}

	f, _ := os.Open("outbound/packet.zip")
	fi, _ := f.Stat()

	h := &handler{outbound: []*hydra.FileOffer{{
		Name:    fi.Name(),
		Size:    fi.Size(),
		ModTime: fi.ModTime(),
		Reader:  f,       // io.ReadSeeker enables resume and RPOS
		Close:   f.Close, // fired exactly once when the file finishes
	}}}

	sess := hydra.NewSession(conn, h, h, nil, &hydra.Config{
		Originator: true,
	})
	defer sess.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
	defer cancel()
	if err := sess.Run(ctx); err != nil { // one batch
		log.Fatal(err)
	}
}

Hydra runs inside a FidoNet mailer session — the EMSI/WaZOO handshake that selects the protocol happens before Run and is out of scope for this library.

Testing

go test ./...          # unit + loopback + conformance
go test -race ./...
HYDRA_TRACE=1 go test -run TestLoopbackSymmetric ./...   # wire trace

The conformance suite pins the compatibility-critical behaviours (RPOS widths, INIT field count, end-of-batch encoding, cancel handling, END counts, braindead rules) against the surveyed C implementations. See SPEC.md for the corrected protocol reference and CLAUDE.md for the pitfall list.

License

MIT

Documentation

Overview

Package hydra is a pure-Go implementation of the HYDRA bidirectional file transfer protocol (FTSC FSC-0072). See SPEC.md for the wire-format details and the compatibility strategy.

Index

Constants

View Source
const DLE byte = 0x18

DLE is Hydra's data-link-escape byte (FSC-0072 H_DLE). Note it is ASCII CAN (0x18), not ASCII DLE (0x10) — Hydra reuses ZMODEM's ZDLE value but applies it on the *outside* of the frame.

View Source
const DataBufMax = 2048

DataBufMax is the maximum data bytes in one DATA packet (excluding the 4-byte offset prefix). FSC-0072 fixes this at 2048.

View Source
const SafeMaxOffset int64 = 0x7FFFFFFE

SafeMaxOffset is the largest file size or offset representable in the 4-byte signed wire fields without colliding with the -1/-2 sentinels.

Variables

View Source
var (
	// ErrSkip is returned by RecvHandler.AcceptFile to refuse a single
	// incoming file. The library answers FINFOACK(-1) ("already have it")
	// on the wire; the batch continues with the next FINFO.
	ErrSkip = errors.New("hydra: skip file")

	// ErrDefer is returned by RecvHandler.AcceptFile to refuse a file for
	// now. The library answers FINFOACK(-2) ("try again in a later
	// batch"); the batch continues.
	ErrDefer = errors.New("hydra: defer file")

	// ErrTransportNotClosable is returned by Session.Run when neither the
	// transport implements io.Closer nor Config.Closer is supplied.
	// Returned before any goroutine is spawned and before the transport
	// is touched, so the caller still owns the transport.
	ErrTransportNotClosable = errors.New("hydra: transport is not closable")

	// ErrFileTooLarge is delivered to FileCompleted (send side when a
	// FileOffer.Size exceeds SafeMaxOffset, receive side when a peer
	// announces such a size). The file never transfers — Hydra's offset
	// fields are 32-bit signed.
	ErrFileTooLarge = errors.New("hydra: file size exceeds 0x7FFFFFFE")

	// ErrBrainDead is returned by Run when the brain-dead watchdog
	// expires (no protocol forward progress for Config.BrainDead).
	ErrBrainDead = errors.New("hydra: brain_dead timeout (no progress)")

	// ErrSessionAborted is returned by Run after Abort, or when the peer
	// signals the out-of-band cancel sequence.
	ErrSessionAborted = errors.New("hydra: session aborted")

	// ErrPeerCancel is returned by Run when the peer sent the DLE cancel
	// sequence (SPEC §2.4).
	ErrPeerCancel = errors.New("hydra: peer sent cancel sequence")

	// ErrMaxRetries is returned by Run when a supervisory packet
	// (START, INIT, FINFO, EOF, END) exceeded Config.MaxRetries
	// retransmissions without an acknowledgement.
	ErrMaxRetries = errors.New("hydra: retransmit limit exceeded")

	// ErrHandlerContract is delivered to FileCompleted when
	// RecvHandler.AcceptFile returns (nil, _, nil) — a handler must
	// return either a non-nil io.WriteCloser or a non-nil error — or
	// when a FileOffer carries a nil Reader.
	ErrHandlerContract = errors.New("hydra: handler returned nil writer with nil error")

	// ErrDevUnsupported is returned by Session.SendDevice when the peer
	// did not negotiate the DEV capability.
	ErrDevUnsupported = errors.New("hydra: peer does not support device data")

	// ErrIncompatible is returned by Run when capability negotiation
	// leaves a vital escape flag unsatisfied — the link cannot be made
	// byte-safe (HydraCom's "Incompatible on this link" abort).
	ErrIncompatible = errors.New("hydra: incompatible capabilities")

	// ErrResumeOutOfRange is delivered to FileCompleted when
	// RecvHandler.AcceptFile returns a resume offset outside
	// [0, announced size]. The library answers FINFOACK(-2); the batch
	// continues.
	ErrResumeOutOfRange = errors.New("hydra: resume offset out of range")

	// ErrSenderSkip is delivered to RecvHandler.FileCompleted when the
	// sender abandoned the file mid-transfer (EOF with a negative
	// offset).
	ErrSenderSkip = errors.New("hydra: sender skipped file")

	// ErrRunActive is returned by Run when another Run on the same
	// Session has not returned yet.
	ErrRunActive = errors.New("hydra: session Run already active")

	// ErrSessionClosed is returned by Run after Close.
	ErrSessionClosed = errors.New("hydra: session closed")

	// ErrDevBusy is returned by SendDevice when the outbound device
	// queue is full. SendDevice never blocks — it may be called from
	// handler callbacks, which run on the session's event loop.
	ErrDevBusy = errors.New("hydra: device queue full")
)

Public sentinel errors.

Functions

func SanitizeFilename

func SanitizeFilename(name string) string

SanitizeFilename strips path components from a peer-provided filename so it is safe to pass to os.Create in the current working directory:

  • filepath.Base on the raw name (strips any directory path)
  • NUL bytes and separators inside the base are replaced with "_"
  • "" / "." / ".." become "_"

Intentionally aggressive — applications needing richer destination-path policy (e.g. honouring FPT paths) should implement their own.

Types

type Config

type Config struct {
	// Originator is true on the side that initiated the connection
	// (FidoNet convention: the caller). Controls the HDXLINK one-way
	// fallback, which only ever triggers on the answerer (SPEC §4.2).
	Originator bool

	// EffectiveBaud feeds the timer and block-size defaults. 0 means a
	// fast reliable link (TCP): 10 s timeout, 2048-byte max blocks.
	EffectiveBaud int

	// AppID is the identification string sent in INIT. Default
	// "2b1aab00gohydra,<version>" (H_REVSTAMP prefix per SPEC §13.1).
	AppID string

	// Supported overrides the advertised capability codes. Default:
	// XON,TLN,CTL,HIC,HI8,C32,DEV,FPT. Codes we cannot honour
	// (ASC, UUE) are stripped.
	Supported []string

	// Desired overrides the requested capability codes. Default: FPT
	// only — escape flags (XON/TLN/CTL/HIC/HI8) are for dirty serial
	// links and cost throughput, so they activate only when a side
	// asks; C32 and DEV activate on mutual support regardless
	// (HydraCom's union rule, SPEC §5 as corrected).
	Desired []string

	// TxWindowBytes / RxWindowBytes advertise sliding-window limits in
	// the INIT packet. 0 (the default) means full streaming, which is
	// what every modern peer expects on reliable transports (SPEC §13.1).
	TxWindowBytes int
	RxWindowBytes int

	// Timeout governs supervisory-packet ack waits. Default
	// max(10 s, min(60 s, 40960/baud)).
	Timeout time.Duration

	// BrainDead is the no-progress watchdog. Default 120 s. Reset rules
	// per SPEC §7 — deliberately narrow.
	BrainDead time.Duration

	// MaxRetries caps consecutive retransmits of one supervisory packet.
	// Default 10.
	MaxRetries int

	// MaxBlockSize caps the DATA block payload. Default and maximum 2048.
	MaxBlockSize int

	// Closer, if non-nil, is closed during Run shutdown to unblock the
	// reader goroutine on transports whose Read cannot otherwise be
	// interrupted. If nil, the library type-asserts the transport for
	// io.Closer; if neither is reachable, Run returns
	// ErrTransportNotClosable at startup.
	Closer io.Closer

	// Logger, if non-nil, receives a Debug record per decoded protocol
	// packet in both directions (type, payload length, offset where the
	// packet carries one), so protocol frames can interleave with a
	// transport-level byte trace. The HYDRA_TRACE=1 stderr trace remains
	// available as a zero-config fallback.
	Logger *slog.Logger
	// contains filtered or unexported fields
}

Config controls Session behaviour. nil selects defaults.

type DeviceHandler

type DeviceHandler interface {
	// OnDeviceData delivers one datagram. device is the peer's 3-char
	// channel name ("CON" chat, "MSG" protocol messages, or custom).
	OnDeviceData(device string, payload []byte)
}

DeviceHandler receives device-channel datagrams (SPEC §3.13). Optional: pass nil to NewSession and incoming DEVDATA is acknowledged and dropped.

type FileInfo

type FileInfo struct {
	Name    string    // short filename as sent by the peer — UNTRUSTED
	Path    string    // full path if the peer sent one (FPT) — UNTRUSTED
	Size    int64     // announced size; 0 means unknown
	ModTime time.Time // zero if the peer sent no timestamp
	// FileNum and FileTotal mirror the FINFO file-count field: on the
	// first file of a batch the peer announces the total; on subsequent
	// files their ordinal (≥2). 0 = unknown. Advisory only (SPEC §3.4).
	FileNum   int
	FileTotal int
}

FileInfo describes an incoming file (parsed from FINFO).

type FileOffer

type FileOffer struct {
	Name    string    // short filename, no path components
	Path    string    // optional full path sent when FPT is negotiated
	Size    int64     // bytes; must be known (Hydra announces it)
	ModTime time.Time // zero means "unknown" (0 on the wire)
	Reader  io.Reader

	// Close, if non-nil, is called exactly once per offered file: when
	// the file's transfer completes (any outcome — sent, skipped,
	// deferred, failed) or when the batch tears down with the file still
	// in flight, whichever comes first. It releases whatever resource
	// backs Reader; its error is not propagated.
	Close func() error
}

FileOffer describes one file the local side wants to send. Reader provides the data; if it also implements io.ReadSeeker the library can honour resume offsets and RPOS repositioning, otherwise a mid-file reposition fails the file.

type RecvHandler

type RecvHandler interface {
	// AcceptFile is invoked for every incoming file. The handler returns
	//
	//	(writer, offset, nil)      -- accept, resume at offset
	//	(nil,    0,      ErrSkip)  -- refuse: already have it (FINFOACK -1;
	//	                              the SENDER counts the file delivered)
	//	(nil,    0,      ErrDefer) -- refuse for now          (FINFOACK -2)
	//	(_,      _,      err)      -- refuse with a custom error (FINFOACK -2)
	//
	// SECURITY: incoming filenames are untrusted. Call SanitizeFilename
	// before using info.Name as a path component.
	AcceptFile(info FileInfo) (io.WriteCloser, int64, error)

	// FileProgress is called periodically during reception with the
	// current byte count. No cadence guarantee.
	FileProgress(info FileInfo, bytesTransferred int64)

	// FileCompleted is invoked exactly once per incoming file when its
	// transfer ends. err is the write-time or protocol error, nil on
	// success. The writer has been Closed before this fires.
	FileCompleted(info FileInfo, bytesTransferred int64, err error)
}

RecvHandler is the application callback interface for the receive half of the session.

type SendHandler

type SendHandler interface {
	// NextFile returns the next file to send, or nil when the local TX
	// batch is finished. Called repeatedly until it returns nil — at
	// that point the library emits the end-of-batch FINFO.
	NextFile() *FileOffer

	// FileProgress is called periodically during transmission with the
	// current byte count. No cadence guarantee.
	FileProgress(info FileInfo, bytesTransferred int64)

	// FileCompleted is invoked exactly once per offered file when its
	// transfer ends. err is nil on success; ErrSkip = the receiver
	// answered "already have it" (delivered as far as the wire is
	// concerned); ErrDefer = the receiver deferred the file to a later
	// batch. The offer's Close hook has already run when this fires.
	FileCompleted(info FileInfo, bytesTransferred int64, err error)
}

SendHandler is the application callback interface for the transmit half of the session. Hydra runs both directions concurrently, so the send and receive callbacks are separate interfaces — an identically named file can be in flight in both directions at once, and each side's FileProgress/FileCompleted must be unambiguous. One object may implement both interfaces and be passed as both arguments (the per-direction method sets do not collide).

type Session

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

Session is one Hydra conversation over a transport. Create via NewSession, drive via Run — once per batch. A batch is one full START/INIT … END cycle; mailers habitually run several per connection (bforce always runs two), so Run leaves the transport open after a clean batch and may be called again. Close releases the transport when the caller is done; Abort tears down mid-batch.

func NewSession

func NewSession(transport io.ReadWriter, send SendHandler, recv RecvHandler, dev DeviceHandler, cfg *Config) *Session

NewSession constructs a Session. Either handler may be nil: a nil send means the local TX batch is immediately empty (end-of-batch FINFO right after negotiation); a nil recv defers every incoming file with FINFOACK(-2), so a receive-less side never marks peer files delivered. One object implementing both interfaces may be passed as both arguments. dev may be nil — incoming DEVDATA is then acknowledged and dropped. cfg may be nil for defaults.

The transport must be closable: either it implements io.Closer or cfg.Closer is set; Run refuses to start otherwise. It is closed on error, on Abort, and by Close — but NOT after a clean batch, so another Run (next batch) can follow.

func (*Session) Abort

func (s *Session) Abort() error

Abort tears the session down from any goroutine: records ErrSessionAborted, emits the cancel sequence (best-effort, bounded), and closes the transport (which also unblocks the reader and any stalled write). The active Run returns the error.

func (*Session) Close

func (s *Session) Close() error

Close releases the transport. Call after the final batch. Safe to call multiple times and alongside Abort. A Run attempted after Close returns ErrSessionClosed.

Close is the teardown BARRIER: it does not return until the reader goroutine has exited (bounded by readerJoinTimeout). Callers may therefore re-arm transport deadlines or reuse the conn immediately after Close without racing a still-parked reader — the property transfer drivers rely on when their transport "Close" is really a deadline poke on a conn they must hand back usable.

func (*Session) Run

func (s *Session) Run(ctx context.Context) error

Run drives one complete batch: autostart/START handshake, INIT negotiation, bidirectional file transfer, END exchange. It returns nil on a clean batch (transport still open, Run may be called again) or the terminal error (transport closed).

Handler callbacks are invoked from Run's goroutine.

Limitation: ctx cancellation is observed between transport writes. If the transport's Write blocks indefinitely (peer stopped reading and the transport buffers filled), only Abort or Close — which close the transport — can interrupt it.

func (*Session) SendDevice

func (s *Session) SendDevice(device string, payload []byte) error

SendDevice queues one device-channel datagram (SPEC §3.13). device should be a 3-character name ("CON" for chat). Returns ErrDevUnsupported once negotiation has shown the peer lacks DEV; before negotiation the datagram is queued optimistically and dropped later if DEV does not activate. Payloads are clamped to DataBufMax.

Non-blocking: a full queue returns ErrDevBusy immediately. It must — handler callbacks run on the session's event loop, and a blocking send from inside one would deadlock the session.

Jump to

Keyboard shortcuts

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