Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
- "**/*.rs"
- "**/Cargo.toml"
- "Cargo.lock"
# README code blocks are compiled as doctests (hardy-btpu).
- "**/README.md"
- ".github/workflows/rust.yml"
- "proto/**"
- "tests/**"
Expand Down Expand Up @@ -143,10 +145,10 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Setup Rust toolchain (32-bit bare-metal target)
- name: Setup Rust toolchain (32-bit bare-metal targets)
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: thumbv7em-none-eabihf
target: thumbv7em-none-eabihf, thumbv6m-none-eabi

- name: Use cached dependencies and artifacts
uses: Swatinem/rust-cache@v2
Expand All @@ -167,10 +169,11 @@ jobs:
# critical-section impl — only final binaries need one.
# TODO: extend to hardy-bpa once its --no-default-features build is
# confirmed clean on this target.
- name: Build cbor (no_std, 32-bit)
- name: Build cbor + btpu (no_std, 32-bit)
run: >
cargo build --locked --no-default-features
-p hardy-cbor
-p hardy-btpu
--target thumbv7em-none-eabihf

- name: Build bpv7 + eid-patterns (no_std, 32-bit)
Expand All @@ -181,6 +184,16 @@ jobs:
-p hardy-eid-patterns
--target thumbv7em-none-eabihf

# thumbv6m-none-eabi (Cortex-M0) has no atomic compare-and-swap at all,
# so alloc::sync::Arc does not exist there and `bytes` needs
# portable-atomic, which btpu's `critical-section` feature enables.
- name: Build btpu (no_std, no atomic CAS)
run: >
cargo build --locked --no-default-features
--features hardy-btpu/critical-section
-p hardy-btpu
--target thumbv6m-none-eabi

msrv:
runs-on: ubuntu-latest
steps:
Expand Down
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ members = [
"bpv7",
"bpv7/tools",
"bpv7/fuzz",
"btpu",
"btpu/fuzz",
"cbor",
"cbor/tools",
"cbor/fuzz",
Expand Down
17 changes: 17 additions & 0 deletions btpu/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Changelog

All notable changes to `hardy-btpu` are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this crate adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Initial release: a `#![no_std]` + `alloc` implementation of the Bundle Transfer Protocol - Unidirectional (draft-ietf-dtn-btpu) with message framing for the FEC extension (draft-ietf-dtn-btpu-fec). The crate is a pure protocol library with no dependency on `hardy-bpa`, `hardy-bpv7`, or an async runtime; a convergence-layer crate composes it with `hardy-bpa`.
- `codec`: the wire format. `decode_pdu` is a lazy, zero-copy iterator with two-tier fault containment: a malformed message interior is skipped via its header length and iteration continues, while a framing fault stops the walk and keeps everything already parsed; an encapsulated bundle of undeterminable extent is reported with its first byte and its offset from the start of the PDU. Unknown message types and their flag bits relay byte-exact. `decode_pdu_with` takes `DecodeOptions`: FEC decoding (off by default, since the provisional FEC type values are Private Use) and a `BundleExtent` hook through which a caller that parses bundles lets the decoder delimit bare bundle frames and encapsulated bundles, trimming link padding and continuing past them per Section 7.3. Hint chains fold to one item per type while decoding, and a malformed Bundle Length hint is carried as an unknown hint rather than failing its message.
- `transfer`: the wraparound-safe receive window (with `reset` for a known sender restart) and the sender's transfer-number allocator, which enforces the Section 5 rule by gating on the span of outstanding numbers rather than their count.
- `sender`: segmentation, PDU packing, and cancellation. A segmented transfer is one queue entry whose segments are cut from the enqueued buffer as PDUs are packed, so a large bundle costs a handle rather than one message per segment. A transfer's window slot is released by the sender itself when its Transfer End is packed into a PDU, since a unidirectional link offers nothing to anchor an explicit completion call to; `cancel` takes any `BundleTransferId` and reports whether it cancelled anything: for a transfer it frees the slot early and only queues a Transfer Cancel if part of the transfer was emitted, placing it at the front of the queue; a Bundle Message or bare frame still queued is simply removed. `SenderConfig` fixes the PDU size, window, queue depth (in queue entries), and `LinkFraming` at construction: fixed-size frames (every PDU padded) or variable-length PDUs (unpadded), the latter optionally emitting a fitting, hint-free bundle as a bare bundle frame through the same queue as everything else. `enqueue` returns a `BundleTransferId` (the transfer number for a segmented bundle, a wrapping counter value otherwise), and `next_pdu` returns a `Pdu`: the packed `Bytes`, sized to its content and sharing a bare frame's buffer rather than copying it, with a `Carried` entry for every bundle it holds bytes of, flagged when it holds the last, so a CLA can report per-bundle outcomes. The list is a `CarriedList`, inline up to four entries; `next_pdu_into` refills a caller-owned one, which keeps its heap buffer once it has one, and `CarriedList::with_capacity` sizes it to the documented bound for valid bundles. `Sender`'s `Debug` summarises its configuration and queue rather than printing queued bundle bytes.
- `receiver`: reassembly, window expiry, duplicate/conflict rejection, and `reset`. `receive_pdu` is infallible: every fault and disposition is a `ReceiverEvent`, so a fault late in a PDU never discards the events before it. A transfer the receiver rejects is reported once with its `RejectReason`, and its later messages are dropped as `DropReason::Rejected` with the same reason. `receive_pdu_into` fills a caller-owned list instead, so a CLA can reuse one allocation for the event list, whose length grows with the number of messages in the PDU. Memory is bounded by construction: the mandatory `MaxBundleSize` polices each transfer's bundle bytes exactly (`TooLarge`) and budgets its per-segment and hint bookkeeping separately (`TooFragmented`); an optional `MaxSegments` replaces the per-segment charge with a direct segment-count limit, and `MaxSegments::for_link_pdu_size` derives a recommended value from the link's PDU size, so honest senders on links with PDUs under 64 bytes are not refused. `MaxRetainedBytes` bounds the state held across all in-progress transfers, rejecting the transfer that would exceed it as `ReceiverFull`; it is enforced as configured, defaults to one transfer's full allowance (2 GiB with the default 1 GiB cap), and `MaxRetainedBytes::for_transfers` derives it from a count of transfers to hold. The `ReceiverConfig` rustdoc covers sizing, and `Receiver::retained_bytes` reports the charged total for export as a metric. Segments shorter than half their PDU and retained hint values are copied out so no PDU is pinned by a fragment, and empty segments (including an empty Transfer End) are stored so a spec-conforming sender always completes. Window expiry costs only the transfers it removes, and `TransferExpired` events are reported oldest first, across the 2³² roll-over included. Delivered, cancelled, and rejected transfer numbers are remembered for the life of the window, so repeats never re-deliver or re-open a transfer, and a Cancel for any in-window number is honoured even before its segments arrive. With FEC decoding on, a transfer that mixes core and FEC messages, or changes its FEC Instance ID, FEC Encoding ID, or FEC message form, is rejected and stays closed, as the FEC draft requires; a transfer that completes with no data is rejected like an empty Bundle Message. `ReceiverConfig` carries the window, the cap, the optional segment limit, the optional retention limit, and the FEC switch.
- Validated configuration newtypes (`PduSize`, `WindowSize`, `MaxBundleSize`, `MaxSegments`, `MaxRetainedBytes`, `SendQueueDepth`) in the `core::num::NonZero` shape (`const fn new`, `const` `get`/`MIN`/`MAX`/`DEFAULT`, `Display` and the binary, octal, and hex formats, `FromStr`, `Hash`, `Ord`, `From` to and from `NonZero*` where only non-zero is required), so invalid sizes are rejected at the edge and no constructor panics. Every `TryFrom` returns the shared `OutOfRange` error, and every `FromStr` the shared `ParseError`. Error and event types are `Clone + PartialEq + Eq`; every module with an error type exposes a `Result` alias.
- Optional features: `serde` (the configuration structs and newtypes, with validation on deserialize), `rand` (`try_from_rng` and `from_rng` constructors seeding the initial transfer number, from a fallible RNG such as `SysRng` or an infallible one), `critical-section` (builds on targets without atomic compare-and-swap, such as Cortex-M0, by moving `bytes` onto `portable-atomic`), and `tower` (`Service<SendRequest>` for `Sender`, an infallible `Service<Bytes>` for `Receiver`, and a `Stream<Item = Pdu>` PDU drain with waker-based backpressure that wakes every parked producer).
- Fuzz targets under `fuzz/` for the decoder (re-encode, byte-exact relay, and decode round trip) and for the receiver (delivered bundles within the cap, a PDU fault only ever last), each across the FEC and extent-hook options.
39 changes: 39 additions & 0 deletions btpu/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[package]
name = "hardy-btpu"
description = "Bundle Transfer Protocol - Unidirectional (BTP-U) codec and transfer logic"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
keywords = ["dtn", "bpv7", "btpu", "convergence-layer", "unidirectional"]
categories = ["network-programming"]

[lib]
path = "src/lib.rs"
crate-type = ["rlib"]

[features]
default = []
serde = ["dep:serde"]
rand = ["dep:rand_core"]
tower = ["dep:tower", "dep:futures-core"]
# Enable this feature on targets without native atomic CAS instructions (e.g., thumbv6m / Cortex-M0).
# Requires a critical-section implementation - see https://docs.rs/portable-atomic
critical-section = ["bytes/extra-platforms", "dep:portable-atomic", "portable-atomic/critical-section"]

[dependencies]
bytes = { version = "1", default-features = false }
thiserror = { version = "2", default-features = false }
serde = { version = "1", default-features = false, features = ["derive"], optional = true }
rand_core = { version = "0.10", default-features = false, optional = true }
tower = { version = "0.5", default-features = false, optional = true }
futures-core = { version = "0.3", default-features = false, optional = true }
portable-atomic = { version = "1", default-features = false, optional = true }

[dev-dependencies]
rand = "0.10"
tower = { version = "0.5", features = ["util", "limit"] }
futures = "0.3"
serde_json = "1"
78 changes: 78 additions & 0 deletions btpu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# hardy-btpu

A `no_std` implementation of the Bundle Transfer Protocol - Unidirectional (BTP-U): the wire codec, the transfer window, and a sender and receiver pair for one-way, frame-based links.

Part of the [Hardy](https://github.com/ricktaylor/hardy) DTN Bundle Protocol implementation.

## Overview

This crate implements [draft-ietf-dtn-btpu](https://datatracker.ietf.org/doc/draft-ietf-dtn-btpu/), which carries bundles over convergence layers that offer no IP services and no return channel (broadcast radio, satellite downlinks, CCSDS frames), and frames the messages of the FEC extension in [draft-ietf-dtn-btpu-fec](https://datatracker.ietf.org/doc/draft-ietf-dtn-btpu-fec/); no FEC scheme is implemented. It is a pure protocol library, `#![no_std]` with `alloc`, with no dependency on `hardy-bpa`, `hardy-bpv7`, or an async runtime, so a convergence-layer crate drives it against the link and composes it with `hardy-bpa`. There is no BTP-U convergence layer in Hardy yet; this crate is the protocol engine one would be built on.

Three layers are exposed, each usable without the ones above it: the `codec` module for PDU encode and decode, the `transfer` module for the window and transfer-number primitives, and the `sender` and `receiver` modules for a ready-to-use engine configured by `SenderConfig` and `ReceiverConfig`.

## Features

- **Segmentation and reassembly**: bundles that fit a PDU travel as a single message; larger ones are cut into segments against the configured PDU size and reassembled at the receiver, with out-of-order and repeated messages handled.
- **Transfer window**: the Section 5 sliding window, enforced on the span of outstanding transfer numbers at the sender and with roll-over at the receiver; the sender releases a transfer's slot itself when its last segment is packed.
- **Fault containment**: receiving is infallible, every fault and policy disposition is a `ReceiverEvent`, and a malformed message is skipped via its header length without discarding the rest of the PDU.
- **Bounded memory**: a mandatory per-transfer cap on reassembled bundle size, with per-segment and hint bookkeeping budgeted separately so a flood of tiny segments cannot exhaust the receiver, and an optional per-transfer segment limit, derivable from the link's PDU size, for links with small frames.
- **Link framing**: fixed-size padded PDUs (CCSDS-style) or variable-length PDUs (datagrams), optionally emitting fitting bundles as bare frames on links shared with raw-bundle peers, through the same queue as everything else.
- **Forward compatibility**: unknown message types, unknown hints, and reserved flag bits relay byte-exact; the provisional FEC message types decode only when switched on.
- Feature flag: `serde` -- `Serialize`/`Deserialize` for `SenderConfig`, `ReceiverConfig`, and the validated newtypes they hold, which re-validate on deserialize.
- Feature flag: `rand` -- `try_from_rng` and `from_rng` constructors that seed the initial transfer number from an RNG, such as the operating system's `rand::rngs::SysRng`.
- Feature flag: `tower` -- `tower::Service` implementations for `Sender` and `Receiver` and a `futures_core::Stream` PDU drain, with waker-based backpressure. Requires `std` at the consumer level.
- Feature flag: `critical-section` -- builds on targets without native atomic compare-and-swap (such as `thumbv6m`, Cortex-M0) by switching `bytes` to `portable-atomic`'s critical-section fallback. Requires a `critical-section` implementation from the HAL or runtime.

## Usage

A loopback, with the `rand` feature enabled: bundles enqueued on a `Sender` are drained as PDUs, fed to a `Receiver`, and come back out as events.

```rust
use bytes::Bytes;
use hardy_btpu::receiver::{Receiver, ReceiverConfig, ReceiverEvent};
use hardy_btpu::sender::{SendOptions, Sender, SenderConfig};
use rand::rngs::SysRng;

// The configuration types validate their spec-defined ranges on
// construction; the defaults (1500-byte PDUs, a window of 16, fixed-size
// framing, a 1 GiB bundle-size cap) are always valid. The spec recommends
// an unpredictable initial transfer number, so the sender draws it from the
// operating system RNG (the `rand` feature); without that feature, pass one
// to `Sender::new`.
let mut sender = Sender::try_from_rng(SenderConfig::default(), &mut SysRng)?;
let mut receiver = Receiver::new(ReceiverConfig::default());

let bundle = Bytes::from(vec![0x9F; 4000]); // stands in for an encoded bundle
let id = sender.enqueue(bundle.clone(), SendOptions::default())?;

// Drain PDUs to the link; here the link is the receiver. A segmented
// transfer's window slot is released when its last PDU is packed, so the
// caller has nothing to acknowledge. Each PDU lists the bundles it
// carries; the one flagged `completes` holds the bundle's last bytes.
let mut delivered = Vec::new();
let mut sent = false;
while let Some(pdu) = sender.next_pdu() {
for event in receiver.receive_pdu(pdu.data) {
match event {
ReceiverEvent::BundleReceived { data, .. } => delivered.push(data),
_ => {} // cancellations, expiries, drops, faults: see ReceiverEvent
}
}
sent |= pdu.bundles.iter().any(|c| c.id == id && c.completes);
}
assert!(sent);
assert_eq!(delivered, vec![bundle]);
# Ok::<(), Box<dyn std::error::Error>>(())
```

Wire-format details, event semantics, memory bounds, and the padding pitfalls of bare bundle frames are documented in the rustdoc.

## Documentation

- [Design](docs/design.md)
- [Changelog](CHANGELOG.md)
- [API Documentation](https://docs.rs/hardy-btpu)

## Licence

Apache 2.0 -- see [LICENSE](../LICENSE)
Loading
Loading