janus

package module
v0.2.1 Latest Latest
Warning

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

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

README

janus

Pure Go implementation of the JANUS bidirectional file transfer protocol.

This is a library package — there is no CLI. Users import the janus package and provide a FileHandler interface implementation to drive simultaneous send-and-receive file transfers over any io.ReadWriter transport that is also closable (TCP sockets, serial ports, PTYs, etc.).

JANUS was originally written by Rick Huebner at Bit Bucket Software for Opus Janus (1988) and BinkleyTerm 0.31 (1989). This implementation follows the qico reference (2005), with selected behavioural enhancements from BinkleyTerm/Ravel.

Features

  • Full-duplex bidirectional file transfer (send and receive simultaneously)
  • CRC-16 (default) and CRC-32 with capability negotiation via JCAP_CRC32
  • Adaptive block sizing with Janus overdrive (up to 2048 bytes)
  • Resume (crash recovery) via FNACKPKT offset, up to the Janus wire limit (0x7FFFFFFE)
  • File requests (FREQ) — fully implemented via the optional FreqHandler interface, advertised via JCAP_FREQ
  • DLE-escape transport encoding (XON/XOFF safe, CR-after-@ Telenet-safe)
  • Answerer-side TX inhibit after 5 consecutive RPOS to break full-duplex livelock on noisy links
  • Interoperable with qico, BinkleyTerm XE, and Ravel

Install

go get github.com/xx25/go-janus

Usage

JANUS is fully bidirectional. A single Session.Run call drives both transmit and receive concurrently. Both peers use the same API; the only difference is Config.Originator — set true on the side that initiated the connection.

Bidirectional session
package main

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

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

// handler implements janus.FileHandler for both directions.
type handler struct {
	files []*janus.FileOffer
	idx   int
}

func (h *handler) NextFile() *janus.FileOffer {
	if h.idx >= len(h.files) {
		return nil
	}
	f := h.files[h.idx]
	h.idx++
	return f
}

func (h *handler) AcceptFile(info janus.FileInfo) (io.WriteCloser, int64, error) {
	// IMPORTANT: always sanitize the filename to prevent path traversal.
	safeName := janus.SanitizeFilename(info.Name)

	f, err := os.Create(safeName)
	if err != nil {
		return nil, 0, err
	}
	return f, 0, nil // offset 0 = receive from start
}

func (h *handler) FileProgress(info janus.FileInfo, bytesTransferred int64) {
	log.Printf("%s: %d / %d bytes", info.Name, bytesTransferred, info.Size)
}

func (h *handler) FileCompleted(info janus.FileInfo, bytesTransferred int64, err error) {
	if err != nil {
		log.Printf("failed %s: %v", info.Name, err)
	} else {
		log.Printf("done %s: %d bytes", info.Name, bytesTransferred)
	}
}

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

	f, err := os.Open("document.txt")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	fi, _ := f.Stat()

	h := &handler{
		files: []*janus.FileOffer{{
			Name:    fi.Name(),
			Size:    fi.Size(),
			ModTime: fi.ModTime(),
			Reader:  f, // io.ReadSeeker enables resume on the sending side
		}},
	}

	sess := janus.NewSession(conn, h, &janus.Config{
		Originator: true, // we initiated the connection
	})

	if err := sess.Run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

The answerer side is identical except Originator: false and net.Listen/Accept instead of Dial.

Skipping files

Return janus.ErrSkip from AcceptFile to refuse an incoming file. The library sends FNACKPKT(-1) on the wire and the peer advances to the next file.

func (h *handler) AcceptFile(info janus.FileInfo) (io.WriteCloser, int64, error) {
	if info.Size > 50*1024*1024 {
		return nil, 0, janus.ErrSkip
	}
	// ...
}
Resuming transfers

Return a non-zero offset from AcceptFile to resume a partially received file. The sender's FileOffer.Reader must implement io.ReadSeeker for the matching send-side resume to work.

func (h *handler) AcceptFile(info janus.FileInfo) (io.WriteCloser, int64, error) {
	safeName := janus.SanitizeFilename(info.Name)

	fi, err := os.Stat(safeName)
	if err == nil && fi.Size() > 0 {
		// Resume: open for append, start at existing size
		f, err := os.OpenFile(safeName, os.O_WRONLY|os.O_APPEND, 0644)
		if err != nil {
			return nil, 0, err
		}
		return f, fi.Size(), nil
	}

	f, err := os.Create(safeName)
	if err != nil {
		return nil, 0, err
	}
	return f, 0, nil
}

Resume offsets must lie in [0, 0x7FFFFFFE] (Janus wire format limitation — the sender's resume check is signed 32-bit). Values outside this range are rejected with janus.ErrResumeOutOfRange.

Configuration

Config controls session behavior:

Field Default Description
Originator false True on the side that initiated the connection
EffectiveBaud 0 Link speed for timer/blocksize formulas; 0 = fast link
MaxBlockSize 2048 Maximum data block (BUFMAX)
MinBlockSize 128 Minimum block after RPOS backoff
DisableCRC32 false (CRC-32 on) Opt out of JCAP_CRC32 advertisement (CRC-32 is on by default)
SupportFreq false Advertise JCAP_FREQ and enable the FreqHandler interface (see File requests below)
Timeout max(10s, 40960/baud) TX retransmit timer for FNAMEPKT / last BLKPKT ack waits
BrainDead 120s Inactivity watchdog (hard abort)
MaxRetries 10 Consecutive TX retransmits before failing a file with ErrMaxRetries
MaxFileSize 0x7FFFFFFE Maximum file size on wire (~2 GiB)
Closer nil Optional io.Closer used to unblock the reader on shutdown

Pass nil for Config to use defaults.

Transport ownership

The transport must be closable: it must implement io.Closer, or you must supply Config.Closer. If neither is available, Run returns janus.ErrTransportNotClosable at startup without touching the transport.

Once Run enters its main loop, the session takes ownership of the transport for the rest of Run and closes it on return. The transport must not be reused for a subsequent session — construct a new one.

Sentinel errors
Error Source
ErrSkip Handler → library: refuse one incoming file
ErrTransportNotClosable Run startup: no closer reachable
ErrFileTooLarge FileCompleted: outgoing offer > MaxFileSize
ErrResumeOutOfRange FileCompleted: resume offset out of range
ErrHandlerContract FileCompleted: handler returned (nil, _, nil)
ErrFreqHandlerRequired Run startup: Config.SupportFreq=true but the handler does not implement FreqHandler
ErrMaxRetries FileCompleted or Run: retransmit budget exhausted
File requests (FREQ)

To originate file requests or serve incoming ones, set Config.SupportFreq=true and implement FreqHandler in addition to FileHandler. The library queries NextRequest() to obtain the next filename to ask the peer for, calls ResolveRequest(name) when the peer sends FREQPKT, and fires RequestDenied(name) when the peer responds with FREQNAKPKT. A successful response arrives as ordinary AcceptFile invocations — the protocol carries no wire-level marker to correlate which incoming file satisfies which request, so peer-side renames (e.g. FILESfiles.bbs) are normal.

type FreqHandler interface {
    NextRequest() string
    ResolveRequest(name string) []*FileOffer
    RequestDenied(name string)
}

If SupportFreq=true but the handler does not also implement FreqHandler, Run returns ErrFreqHandlerRequired at startup.

Security

  • Path traversal: incoming filenames may contain ../. The library does not sanitize automatically. Use janus.SanitizeFilename() in your AcceptFile implementation.
  • File size limits: Config.MaxFileSize defaults to the maximum safe Janus wire offset (0x7FFFFFFE, ~2 GiB) and rejects larger offers before they reach the wire.
  • Resume offset limits: resume offsets outside [0, 0x7FFFFFFE] are rejected as ErrResumeOutOfRange; the library sends FNACKPKT(-1) so the peer advances cleanly.

License

See LICENSE for details.

Documentation

Overview

Package janus is a pure-Go implementation of the JANUS bidirectional file transfer protocol. See SPEC.md for the wire-format details.

Index

Constants

View Source
const (
	JCAP_CRC32  byte = 0x80 // sender can encode CRC-32 on non-FNAMEPKT frames
	JCAP_FREQ   byte = 0x40 // sender supports file requests
	JCAP_CLEAR8 byte = 0x20 // reserved; not implemented anywhere
	JCAP_KNOWN       = JCAP_CRC32 | JCAP_FREQ | JCAP_CLEAR8
)

Capability flags carried in the trailing byte of FNAMEPKT (and optionally FNACKPKT). Mirrors qico janus.h:54-57.

View Source
const (
	DLE  byte = 0x10
	XON  byte = 0x11
	XOFF byte = 0x13
	CR   byte = 0x0D
	AT   byte = 0x40
)

DLE escape framing bytes.

View Source
const BufMax = 2048

BufMax is the maximum per-packet data payload (qico BUFMAX). BLKPKT carries a 4-byte offset prefix plus up to BufMax data bytes — a fully populated BLKPKT payload is therefore 4 + BufMax = 2052 bytes.

View Source
const SafeMaxOffset int64 = 0x7FFFFFFE

SafeMaxOffset is the largest file size or resume offset the protocol can safely carry on the wire. qico's TX-side resume check is signed ((txpos = FETCH32(rxbuf)) > -1L), so any value with the top bit set is interpreted as the "skip" sentinel rather than a real offset.

Variables

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

	// ErrRefuseBatch is returned by RecvHandler.AcceptFile to refuse the
	// offered file AND terminate the whole session, non-destructively
	// for the peer's queue: the library sends NO FNACKPKT for the
	// refused file (FNACKPKT(-1) would mean "skip — count it handled"
	// and the peer would clear the file), it just initiates the HALT
	// shutdown. The peer's in-flight offer then ends via its abort
	// teardown — aborted and requeued for a later session. Run returns
	// ErrSessionAborted. Use this instead of calling Session.Abort from
	// inside AcceptFile so the library controls the shutdown sequence.
	ErrRefuseBatch = errors.New("janus: refuse file and abort batch")

	// 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("janus: transport is not closable")

	// ErrFileTooLarge is delivered to FileCompleted (on whichever side
	// rejected the file) when a FileOffer.Size or announced incoming size
	// exceeds Config.MaxFileSize (default 0x7FFFFFFE). The file is never
	// transferred on the wire.
	ErrFileTooLarge = errors.New("janus: file size exceeds 0x7FFFFFFE")

	// ErrResumeOutOfRange is delivered to FileCompleted when a resume
	// offset falls outside [0, 0x7FFFFFFE] (RecvHandler.AcceptFile
	// returned one, or the peer requested one). The library answers
	// FNACKPKT(-1) on the wire; the batch continues.
	ErrResumeOutOfRange = errors.New("janus: resume offset outside [0, 0x7FFFFFFE]")

	// ErrHandlerContract is delivered to FileCompleted when a handler
	// violates its contract — RecvHandler.AcceptFile returned
	// (nil, _, nil), or a FileOffer carried a nil Reader. The library
	// answers FNACKPKT(-1) on the wire (receive side) or drops the offer
	// locally (send side); the batch continues.
	ErrHandlerContract = errors.New("janus: handler returned nil writer with nil error")

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

	// ErrSessionAborted is returned by Run after Abort.
	ErrSessionAborted = errors.New("janus: session aborted")

	// ErrPeerHalt is the sentinel for peer-initiated HALTPKT. The
	// library no longer records it automatically (peer-initiated HALT
	// is the normal shutdown path), but it remains exposed for callers
	// that wish to surface the cause themselves via the Session API.
	ErrPeerHalt = errors.New("janus: peer halted")

	// ErrMaxRetries is delivered to SendHandler.FileCompleted when a
	// TX-side retransmission of FNAMEPKT or BLKPKT exceeds
	// Config.MaxRetries with no acknowledgement.
	ErrMaxRetries = errors.New("janus: retransmit limit exceeded")

	// ErrFreqHandlerRequired is returned by Session.Run at startup when
	// Config.SupportFreq is true but no FreqHandler was supplied to
	// NewSession. Advertising JCAP_FREQ without a way to satisfy or
	// originate requests is a configuration error.
	ErrFreqHandlerRequired = errors.New("janus: Config.SupportFreq=true requires a non-nil FreqHandler")
)

Public sentinel errors. See PLAN.md "Public sentinels".

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 or os.OpenFile in the current working directory. Behaviour:

  • filepath.Base on the raw name (strips any directory path)
  • leading dots and slashes are removed
  • if the result is "" or "." or "..", returns "_"
  • NUL bytes and path separators inside the base are replaced with "_"

This is intentionally aggressive — applications that need richer destination-path policy should ignore this helper and implement their own.

Types

type BlkPkt

type BlkPkt struct {
	Offset uint32
	Data   []byte
}

BlkPkt is the BLKPKT body: 4-byte LE offset + 1..BufMax data bytes.

type Config

type Config struct {
	// Originator is true on the side that initiated the connection. Used
	// as a tiebreaker for the 5-consecutive-RPOS TX inhibit (BinkleyTerm
	// behaviour layered on top of qico's wire spec — SPEC §14.3).
	Originator bool

	// EffectiveBaud feeds the timer/blocksize defaults. 0 means "treat
	// as a fast link" — use BUFMAX and the 10 s timeout floor.
	EffectiveBaud int

	// MaxBlockSize caps the per-block payload. Default BufMax (2048).
	MaxBlockSize int

	// MinBlockSize is the floor after RPOS backoff. Default 128 (qico
	// value).
	MinBlockSize int

	// DisableCRC32 opts out of advertising JCAP_CRC32. CRC-32 is enabled
	// by default — set this true to fall back to CRC-16 for non-FNAMEPKT
	// frames (useful for testing or for talking to a peer that mis-handles
	// CRC-32 negotiation).
	DisableCRC32 bool

	// SupportFreq advertises JCAP_FREQ. Default false — FREQ is
	// recognised on the wire but not driven from the library in v1.
	SupportFreq bool

	// Timeout governs TX ack waits. Default max(10s, 40960/baud).
	Timeout time.Duration

	// BrainDead is the no-progress watchdog. Default 120s. Resets only
	// on TX successful XSENDBLK or RX good BLKPKT at the expected
	// offset (qico janus.c:166, 228).
	BrainDead time.Duration

	// MaxRetries caps consecutive TX retransmits (FNAMEPKT after a lost
	// FNACK, or the last BLKPKT after a lost EOFACK) before the file
	// is failed with ErrMaxRetries and the session aborts. Default 10.
	MaxRetries int

	// MaxFileSize is the largest file the library is willing to send.
	// Default SafeMaxOffset (0x7FFFFFFE). Larger offers are skipped at
	// TX-time with ErrFileTooLarge.
	MaxFileSize int64

	// 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
}

Config controls Session behaviour. nil is acceptable and selects defaults.

type FileInfo

type FileInfo struct {
	Name    string
	Size    int64
	ModTime time.Time
	Mode    uint32
}

FileInfo describes an incoming file (parsed from FNAMEPKT).

type FileOffer

type FileOffer struct {
	Name    string
	Size    int64
	ModTime time.Time
	Mode    uint32
	Reader  io.Reader

	// Close, if non-nil, releases the resources backing Reader. The
	// library invokes it exactly once per offer handed to it, on every
	// path: after the offer's FileCompleted when the transfer started or
	// was rejected, or during session teardown for offers that never
	// reached the wire (e.g. FREQ-resolved offers still queued at abort).
	Close func() error
}

FileOffer describes one file the sender wants to push to the peer. Reader provides the file data. If Reader also implements io.ReadSeeker, the library can honour a non-zero resume offset returned by the peer's AcceptFile; otherwise a non-zero resume causes the file to be skipped.

type FnackPkt

type FnackPkt struct {
	Offset  int64 // -1 means skip
	HasCaps bool
	Caps    byte
}

FnackPkt is the FNACKPKT body: 4-byte LE signed offset (interpreted via uint32 → int64), optionally followed by a 1-byte caps echo (present iff payload length ≥ 5). Offset 0xFFFFFFFF means "skip this file".

type FnamePkt

type FnamePkt struct {
	Name  string
	Size  int64
	Mtime int64 // Unix seconds (octal on the wire)
	Mode  uint32
	Caps  byte
}

FnamePkt is the parsed FNAMEPKT body: filename + size/mtime/mode + caps. An empty name signals end-of-batch.

type FreqHandler

type FreqHandler interface {
	// NextRequest returns the next filename the local side wants to
	// request from the peer, or "" when no more requests are pending.
	// The call is destructive — the library treats each non-empty return
	// value as one request attempt and will not call NextRequest again
	// until that attempt has resolved (peer sent a non-EOB FNAMEPKT in
	// response) or been denied (peer sent FREQNAKPKT). FREQPKT
	// retransmits caused by peer EOB retransmits are handled internally
	// and do NOT trigger additional NextRequest calls.
	NextRequest() string

	// ResolveRequest is called when the peer sends FREQPKT. Return zero
	// or more FileOffers to satisfy the request; the library will
	// transmit each as a regular file in the local TX batch. Returning
	// a nil or empty slice causes the library to NAK with FREQNAKPKT.
	// The name argument is the peer's raw request string — wildcard or
	// magic-alias resolution is the handler's concern.
	ResolveRequest(name string) []*FileOffer

	// RequestDenied is fired when the peer responds to our outgoing
	// request with FREQNAKPKT. There is no positive-ack callback:
	// successful responses arrive as ordinary AcceptFile invocations,
	// and the protocol provides no wire-level way to correlate which
	// incoming file (or files) satisfy which request.
	RequestDenied(name string)
}

FreqHandler enables FREQPKT (file-request) support. It is supplied to NewSession as its own argument; Session.Run returns ErrFreqHandlerRequired when Config.SupportFreq is true but the FreqHandler is nil. Callbacks for FREQ-resolved outbound files flow through the SendHandler like any other offer.

type FreqPkt

type FreqPkt struct {
	Name string
	Caps byte
}

FreqPkt is the FREQPKT body: requested filename + caps byte. Spec: payload is "request_name\0caps".

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 this one file
	//   (nil,    0,      ErrRefuseBatch)  -- refuse file AND end session
	//   (_,      _,      err)             -- refuse with a custom error
	//
	// On any non-nil error the library answers FNACKPKT(-1) on the wire;
	// with ErrRefuseBatch it then initiates the HALT shutdown, otherwise
	// the batch continues. Custom errors are surfaced verbatim via
	// FileCompleted.
	//
	// 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 transfer with the
	// current byte count. The library makes no guarantee about cadence.
	FileProgress(info FileInfo, bytesTransferred int64)

	// FileCompleted is invoked exactly once per incoming file that was
	// offered, when its transfer ends — on every path, including session
	// teardown while the file was mid-receive (the writer is closed
	// first; err is then ErrSessionAborted or the fatal error). err is
	// nil on success.
	FileCompleted(info FileInfo, bytesTransferred int64, err error)
}

RecvHandler is the application callback interface for the RX half of a session. All three methods are invoked from the session's RX goroutine. A nil RecvHandler at NewSession means every incoming file is skipped with FNACKPKT(-1). NOTE: unlike a non-destructive defer, a Janus skip tells the peer not to retry — the peer will treat skipped files as handled.

type RposPkt

type RposPkt struct {
	Offset    uint32
	Timestamp uint32
}

RposPkt is the RPOSPKT body: expected offset + Unix timestamp (used for dedup — see SPEC §4.4).

type SendHandler

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

	// FileProgress is called periodically during transfer with the
	// current byte count. The library makes no guarantee about cadence.
	FileProgress(info FileInfo, bytesTransferred int64)

	// FileCompleted is invoked exactly once per outbound file when its
	// transfer ends — on every path, including session teardown while
	// the file was mid-flight (err is then ErrSessionAborted or the
	// fatal error). err is nil on success.
	FileCompleted(info FileInfo, bytesTransferred int64, err error)
}

SendHandler is the application callback interface for the TX half of a session. All three methods are invoked from the session's TX goroutine. A nil SendHandler at NewSession means "nothing to send" — the library emits an end-of-batch FNAMEPKT immediately (FREQ-resolved offers, which also flow through TX, then have their callbacks dropped).

type Session

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

Session is one bidirectional JANUS conversation. Create via NewSession, drive via Run, optionally interrupt via Abort.

func NewSession

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

NewSession constructs a Session bound to the given transport and handlers. send drives the TX half (nil = nothing to offer), recv drives the RX half (nil = skip all incoming files), freq serves FREQPKT file requests (nil unless Config.SupportFreq). cfg may be nil — defaults are then applied.

The transport is owned by the session for the lifetime of Run. After Run returns (with any error other than ErrTransportNotClosable or ErrFreqHandlerRequired), the transport has been closed and must not be reused.

func (*Session) Abort

func (s *Session) Abort() error

Abort initiates a clean shutdown by sending HALTPKT and tearing down. It is safe to call from any goroutine, including handler callbacks. Returns nil; the asynchronous Run will return ErrSessionAborted (or the underlying error if shutdown already started).

func (*Session) Run

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

Run drives both halves of the session until each terminates (clean or otherwise). The transport is closed before Run returns.

ErrTransportNotClosable is returned without touching the transport when the transport is neither io.Closer itself nor backed by Config.Closer.

Jump to

Keyboard shortcuts

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