Three features, all zero-toggle and principle-clean (single static musl
binary, container-first, no test certs, no client trust-store changes).
Secure Boot via signed shim+GRUB (automatic):
- The v0.6.1 escalation ladder gains a third rung: Firmware -> Builtin
-> Shim. Secure-Boot firmware downloads our unsigned iPXE but refuses
to execute it — indistinguishable from a failed chainload — so after
two unconfirmed attempts the MAC is offered Fedora's Microsoft-signed
shimx64.efi, which loads the signed GRUB, which fetches a
server-rendered grub.cfg. Fully signed chain, SB stays on.
- scripts/fetch-shim.sh pulls shim-x64/grub2-efi-x64 (+aa64 best-effort)
from the official Fedora 43 packages and ships the EFI binaries
byte-for-byte unmodified; Dockerfile fetch stage gained rpm2cpio/cpio.
- New grub_script renderer (Linux kernel entries only — signed GRUB only
boots signed kernels; sanboot/wimboot have no signed equivalent and
are omitted with an explanatory menu line).
- TFTP server gains a DynamicAsset hook for server-rendered names
(grub.cfg); HTTP serves the same config under /ipxe/grub.cfg for
native UEFI HTTP Boot chains. Arch-aware fallback walks back down the
ladder where no shim exists (BIOS, IA32).
Boot rules + decision webhook (open 'Matrix Boot'):
- Ordered first-match-wins rules over MAC prefix + client arch (the DHCP
proxy now bakes arch into the boot.ipxe chain URL), generalizing
per-MAC pins. Persisted to boot_rules.json; GET/PUT /api/boot-rules;
rules editor + webhook field on the Hosts tab.
- Optional pixiecore-style webhook: unmatched boots GET
<url>?mac=&arch= and 200 {"target":"id"} chains to it. Fail-open
with a 2s budget — a dead endpoint can never block PXE.
- Decision order: exact pin -> rules -> webhook -> menu. Empty config
is byte-for-byte the previous behavior.
Tokenized answer files (the post-WDS/CVE-2026-0386 hardening):
- Every generated unattended URL (inst.ks / preseed url / autoinstall
seed) now carries a 4h boot-scoped token; /unattended/{id} and the
cloud-init seed routes require it (or an operator session) once an
admin exists. Stops answer-file credential harvesting by anything
else on the network. No toggle; setup-mode installs stay open.
Validation: clippy clean, fmt clean, 290 workspace tests green
(+18 new across boot_tokens, boot_rules, arch ladder, escalation,
grub renderer, and four new full-flow integration tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
531 lines
18 KiB
Rust
531 lines
18 KiB
Rust
//! TFTP server implementation.
|
|
//!
|
|
//! Design: the main socket on :69 accepts RRQ packets. For each RRQ we spawn
|
|
//! a task that creates a new ephemeral UDP socket and handles the full
|
|
//! transfer there (per RFC 1350 — each transfer uses its own port pair so
|
|
//! multiple clients can download concurrently). This matches exactly how
|
|
//! `tftpd`/`in.tftpd` works and is why TFTP is awkward behind stateful NAT:
|
|
//! the ephemeral ports must be reachable from the client.
|
|
//!
|
|
//! We only serve files from `openpxe_ipxe_assets::asset_slice` — that is,
|
|
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
|
|
//! `../` path traversal attempts simply return ENOENT.
|
|
|
|
use openpxe_core::{ClientEvent, ClientRegistry};
|
|
use openpxe_ipxe_assets::asset_slice;
|
|
use socket2::{Domain, Protocol, Socket, Type};
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::net::UdpSocket;
|
|
|
|
// TFTP opcodes.
|
|
const OP_RRQ: u16 = 1;
|
|
const OP_WRQ: u16 = 2;
|
|
const OP_DATA: u16 = 3;
|
|
const OP_ACK: u16 = 4;
|
|
const OP_ERROR: u16 = 5;
|
|
const OP_OACK: u16 = 6;
|
|
|
|
// Error codes (RFC 1350).
|
|
const ERR_NOT_DEFINED: u16 = 0;
|
|
const ERR_FILE_NOT_FOUND: u16 = 1;
|
|
const ERR_ILLEGAL_OP: u16 = 4;
|
|
|
|
/// Server-rendered TFTP content for names that aren't embedded assets —
|
|
/// e.g. `grub.cfg` for the signed shim+GRUB Secure Boot chain (v0.7.0),
|
|
/// which is generated from the live boot-entry list per fetch. Kept as a
|
|
/// closure so this crate stays decoupled from the ISO store; the binary
|
|
/// wires it up in `main`.
|
|
pub type DynamicAsset = Arc<dyn Fn(&str) -> Option<Vec<u8>> + Send + Sync>;
|
|
|
|
pub struct TftpServer {
|
|
bind: IpAddr,
|
|
port: u16,
|
|
clients: Arc<ClientRegistry>,
|
|
metrics: openpxe_core::Metrics,
|
|
dynamic: Option<DynamicAsset>,
|
|
}
|
|
|
|
impl TftpServer {
|
|
pub fn new(
|
|
bind: IpAddr,
|
|
port: u16,
|
|
clients: Arc<ClientRegistry>,
|
|
metrics: openpxe_core::Metrics,
|
|
dynamic: Option<DynamicAsset>,
|
|
) -> Self {
|
|
Self {
|
|
bind,
|
|
port,
|
|
clients,
|
|
metrics,
|
|
dynamic,
|
|
}
|
|
}
|
|
|
|
pub async fn run(self) -> anyhow::Result<()> {
|
|
let sock = bind_udp(self.bind, self.port)?;
|
|
tracing::info!(target: "openpxe::tftp", "TFTP listening on {}:{}", self.bind, self.port);
|
|
let clients = self.clients.clone();
|
|
let metrics = self.metrics.clone();
|
|
let mut buf = vec![0u8; 2048];
|
|
loop {
|
|
let (n, from) = match sock.recv_from(&mut buf).await {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(target: "openpxe::tftp", "recv error: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
let data = buf[..n].to_vec();
|
|
let clients = clients.clone();
|
|
let metrics = metrics.clone();
|
|
let bind_ip = self.bind;
|
|
let dynamic = self.dynamic.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(e) =
|
|
handle_rrq(data, from, bind_ip, clients, metrics.clone(), dynamic).await
|
|
{
|
|
metrics.record_tftp_err();
|
|
tracing::warn!(target: "openpxe::tftp", peer=%from, "handler error: {e}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_rrq(
|
|
packet: Vec<u8>,
|
|
peer: SocketAddr,
|
|
bind_ip: IpAddr,
|
|
clients: Arc<ClientRegistry>,
|
|
metrics: openpxe_core::Metrics,
|
|
dynamic: Option<DynamicAsset>,
|
|
) -> anyhow::Result<()> {
|
|
let Some(req) = parse_rrq(&packet) else {
|
|
// Not a well-formed RRQ. A WRQ deserves an explicit refusal —
|
|
// legacy clients retry a silently-dropped write until they time
|
|
// out; an ERROR packet fails them fast with a readable reason.
|
|
if packet.len() >= 2 && u16::from_be_bytes([packet[0], packet[1]]) == OP_WRQ {
|
|
let sock = bind_udp(bind_ip, 0)?;
|
|
let _ = send_error(&sock, peer, ERR_ILLEGAL_OP, "writes not supported").await;
|
|
}
|
|
return Ok(());
|
|
};
|
|
let Request {
|
|
filename,
|
|
mode,
|
|
options,
|
|
} = req;
|
|
|
|
// Per-transfer ephemeral socket.
|
|
let sock = bind_udp(bind_ip, 0)?;
|
|
|
|
// We serve binary boot artifacts; netascii line-ending translation
|
|
// would corrupt them. Refuse loudly instead of timing out silently —
|
|
// matters for legacy clients that default to netascii.
|
|
if !mode.eq_ignore_ascii_case("octet") {
|
|
let _ = send_error(&sock, peer, ERR_NOT_DEFINED, "only octet mode is supported").await;
|
|
tracing::info!(target: "openpxe::tftp", peer=%peer, %mode, "rejected non-octet transfer");
|
|
return Ok(());
|
|
}
|
|
|
|
// Embedded assets first; otherwise the dynamic renderer (server-
|
|
// generated content like the Secure Boot chain's grub.cfg, v0.7.0).
|
|
let resolved = asset_slice(&filename).or_else(|| {
|
|
dynamic
|
|
.as_ref()
|
|
.and_then(|f| f(&filename))
|
|
.map(std::borrow::Cow::Owned)
|
|
});
|
|
let Some(file_bytes) = resolved else {
|
|
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
|
tracing::info!(target: "openpxe::tftp", peer=%peer, file=%filename, "404");
|
|
clients.record(
|
|
&peer.ip().to_string(),
|
|
Some(peer.ip()),
|
|
None,
|
|
ClientEvent::TftpRead {
|
|
file: filename.clone(),
|
|
},
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
tracing::info!(
|
|
target: "openpxe::tftp",
|
|
peer=%peer, file=%filename, size=file_bytes.len(),
|
|
"serving"
|
|
);
|
|
clients.record(
|
|
&peer.ip().to_string(),
|
|
Some(peer.ip()),
|
|
None,
|
|
ClientEvent::TftpRead {
|
|
file: filename.clone(),
|
|
},
|
|
);
|
|
|
|
// Negotiate options.
|
|
let mut blksize: usize = 512;
|
|
let mut window: u16 = 1;
|
|
let mut accepted_opts: Vec<(String, String)> = Vec::new();
|
|
|
|
for (k, v) in &options {
|
|
match k.as_str() {
|
|
"blksize" => {
|
|
if let Ok(n) = v.parse::<usize>() {
|
|
blksize = n.clamp(8, 65464);
|
|
accepted_opts.push(("blksize".into(), blksize.to_string()));
|
|
}
|
|
}
|
|
"tsize" => {
|
|
accepted_opts.push(("tsize".into(), file_bytes.len().to_string()));
|
|
}
|
|
"windowsize" => {
|
|
if let Ok(n) = v.parse::<u16>() {
|
|
window = n.clamp(1, 64);
|
|
accepted_opts.push(("windowsize".into(), window.to_string()));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if !accepted_opts.is_empty() {
|
|
let oack = encode_oack(&accepted_opts);
|
|
// Loop until client ACKs block 0 (the OACK).
|
|
if !wait_for_ack(&sock, peer, 0, &oack).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// DATA transfer. Block numbers are u16 and may wrap at 65535 — we handle
|
|
// that via `wrapping_add`. For each window we remember the starting
|
|
// (offset, block_no) explicitly; on retransmit we replay from there
|
|
// instead of trying to compute it back from `last_block_in_window` which
|
|
// is wrong when a window short-sends at EOF (previous bug: window=8 but
|
|
// only 3 blocks sent, then rewind subtracted 7 landing in the wrong id).
|
|
let total = file_bytes.len();
|
|
let mut offset: usize = 0;
|
|
let mut block_no: u16 = 1;
|
|
// Per RFC 1350: if the final data block is exactly blksize, the
|
|
// server must follow up with a zero-length DATA so the client knows
|
|
// the transfer has ended. The flag is set inside the loop and
|
|
// tested at end-of-transfer.
|
|
let needs_zero_final;
|
|
|
|
'transfer: loop {
|
|
let window_start_offset = offset;
|
|
let window_start_block = block_no;
|
|
let mut last_block_in_window = block_no;
|
|
let mut window_reached_eof = false;
|
|
let mut last_chunk_len = 0usize;
|
|
|
|
// Send one window worth of DATA.
|
|
for _ in 0..window {
|
|
if offset >= total {
|
|
break;
|
|
}
|
|
let end = (offset + blksize).min(total);
|
|
let chunk = &file_bytes[offset..end];
|
|
let pkt = encode_data(block_no, chunk);
|
|
sock.send_to(&pkt, peer).await?;
|
|
last_block_in_window = block_no;
|
|
last_chunk_len = chunk.len();
|
|
offset = end;
|
|
block_no = block_no.wrapping_add(1);
|
|
if end == total {
|
|
window_reached_eof = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Wait for an ACK of the last block we actually sent (not the
|
|
// nominal last block of a theoretical full window).
|
|
let mut tries = 0u8;
|
|
loop {
|
|
match tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await {
|
|
Ok(Ok(acked)) if acked == last_block_in_window => break,
|
|
Ok(Ok(_)) => {} // stale ACK from an earlier block — ignore
|
|
Ok(Err(e)) => return Err(e),
|
|
Err(_) => {
|
|
tries += 1;
|
|
if tries > 5 {
|
|
tracing::warn!(
|
|
target: "openpxe::tftp",
|
|
peer=%peer, last_block=last_block_in_window,
|
|
"timeout after {tries} retries, aborting transfer"
|
|
);
|
|
return Ok(());
|
|
}
|
|
// Rewind to the start of this window and resend exactly
|
|
// the same blocks (same count, same block numbers). This
|
|
// is cheap and correct even for short final windows.
|
|
offset = window_start_offset;
|
|
block_no = window_start_block;
|
|
let mut resent = 0;
|
|
while resent < window && offset < total {
|
|
let end = (offset + blksize).min(total);
|
|
let pkt = encode_data(block_no, &file_bytes[offset..end]);
|
|
sock.send_to(&pkt, peer).await?;
|
|
last_block_in_window = block_no;
|
|
last_chunk_len = end - offset;
|
|
offset = end;
|
|
block_no = block_no.wrapping_add(1);
|
|
resent += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if window_reached_eof || offset >= total {
|
|
// If the very last DATA was exactly blksize, RFC 1350 requires a
|
|
// following zero-length DATA to signal end-of-transfer. If it
|
|
// was shorter, the short block already signals EOF.
|
|
needs_zero_final = last_chunk_len == blksize;
|
|
break 'transfer;
|
|
}
|
|
}
|
|
|
|
if needs_zero_final {
|
|
let pkt = encode_data(block_no, &[]);
|
|
sock.send_to(&pkt, peer).await?;
|
|
let _ = tokio::time::timeout(Duration::from_secs(3), recv_ack(&sock, peer)).await;
|
|
}
|
|
|
|
tracing::debug!(target: "openpxe::tftp", peer=%peer, bytes=total, "transfer complete");
|
|
metrics.record_tftp_ok(total as u64);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Request {
|
|
filename: String,
|
|
mode: String,
|
|
options: Vec<(String, String)>,
|
|
}
|
|
|
|
fn parse_rrq(pkt: &[u8]) -> Option<Request> {
|
|
if pkt.len() < 4 {
|
|
return None;
|
|
}
|
|
let op = u16::from_be_bytes([pkt[0], pkt[1]]);
|
|
if op != OP_RRQ {
|
|
return None;
|
|
}
|
|
let mut rest = &pkt[2..];
|
|
let filename = read_cstr(&mut rest)?;
|
|
let mode = read_cstr(&mut rest)?;
|
|
let mut options = Vec::new();
|
|
while !rest.is_empty() {
|
|
let Some(k) = read_cstr(&mut rest) else { break };
|
|
if k.is_empty() {
|
|
break;
|
|
}
|
|
let v = read_cstr(&mut rest).unwrap_or_default();
|
|
options.push((k.to_ascii_lowercase(), v));
|
|
}
|
|
Some(Request {
|
|
filename,
|
|
mode,
|
|
options,
|
|
})
|
|
}
|
|
|
|
fn read_cstr(buf: &mut &[u8]) -> Option<String> {
|
|
let pos = buf.iter().position(|b| *b == 0)?;
|
|
let s = std::str::from_utf8(&buf[..pos]).ok()?.to_string();
|
|
*buf = &buf[pos + 1..];
|
|
Some(s)
|
|
}
|
|
|
|
fn encode_data(block: u16, chunk: &[u8]) -> Vec<u8> {
|
|
let mut v = Vec::with_capacity(4 + chunk.len());
|
|
v.extend_from_slice(&OP_DATA.to_be_bytes());
|
|
v.extend_from_slice(&block.to_be_bytes());
|
|
v.extend_from_slice(chunk);
|
|
v
|
|
}
|
|
|
|
fn encode_oack(opts: &[(String, String)]) -> Vec<u8> {
|
|
let mut v = Vec::new();
|
|
v.extend_from_slice(&OP_OACK.to_be_bytes());
|
|
for (k, val) in opts {
|
|
v.extend_from_slice(k.as_bytes());
|
|
v.push(0);
|
|
v.extend_from_slice(val.as_bytes());
|
|
v.push(0);
|
|
}
|
|
v
|
|
}
|
|
|
|
async fn send_error(
|
|
sock: &UdpSocket,
|
|
peer: SocketAddr,
|
|
code: u16,
|
|
msg: &str,
|
|
) -> std::io::Result<()> {
|
|
let mut v = Vec::with_capacity(5 + msg.len());
|
|
v.extend_from_slice(&OP_ERROR.to_be_bytes());
|
|
v.extend_from_slice(&code.to_be_bytes());
|
|
v.extend_from_slice(msg.as_bytes());
|
|
v.push(0);
|
|
sock.send_to(&v, peer).await.map(|_| ())
|
|
}
|
|
|
|
async fn recv_ack(sock: &UdpSocket, peer: SocketAddr) -> anyhow::Result<u16> {
|
|
let mut buf = [0u8; 32];
|
|
loop {
|
|
let (n, from) = sock.recv_from(&mut buf).await?;
|
|
if from.ip() != peer.ip() {
|
|
continue;
|
|
}
|
|
if n < 4 {
|
|
continue;
|
|
}
|
|
let op = u16::from_be_bytes([buf[0], buf[1]]);
|
|
match op {
|
|
OP_ACK => return Ok(u16::from_be_bytes([buf[2], buf[3]])),
|
|
OP_ERROR => {
|
|
let code = u16::from_be_bytes([buf[2], buf[3]]);
|
|
anyhow::bail!("client error {code}");
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_ack(
|
|
sock: &UdpSocket,
|
|
peer: SocketAddr,
|
|
expect_block: u16,
|
|
to_retx: &[u8],
|
|
) -> anyhow::Result<bool> {
|
|
let mut tries = 0;
|
|
loop {
|
|
sock.send_to(to_retx, peer).await?;
|
|
match tokio::time::timeout(Duration::from_secs(3), recv_ack(sock, peer)).await {
|
|
Ok(Ok(b)) if b == expect_block => return Ok(true),
|
|
Ok(Ok(_)) => {}
|
|
Ok(Err(_)) | Err(_) => {
|
|
tries += 1;
|
|
if tries > 5 {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn bind_udp(bind: IpAddr, port: u16) -> anyhow::Result<UdpSocket> {
|
|
let domain = match bind {
|
|
IpAddr::V4(_) => Domain::IPV4,
|
|
IpAddr::V6(_) => Domain::IPV6,
|
|
};
|
|
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
|
|
sock.set_reuse_address(true)?;
|
|
sock.set_nonblocking(true)?;
|
|
let addr: SocketAddr = SocketAddr::new(bind, port);
|
|
sock.bind(&addr.into())?;
|
|
let std_sock: std::net::UdpSocket = sock.into();
|
|
Ok(UdpSocket::from_std(std_sock)?)
|
|
}
|
|
|
|
/// Pure-logic mirror of `handle_rrq`'s windowing math, exercised by the
|
|
/// unit tests below. Given a position in the file and the window, return
|
|
/// the (block_no, chunk_len) list this window will emit — tested against
|
|
/// edge cases (exact-blksize tail, short tail, single-block window,
|
|
/// block-number wraparound).
|
|
#[cfg(test)]
|
|
fn plan_window(
|
|
total: usize,
|
|
offset: usize,
|
|
blksize: usize,
|
|
window: u16,
|
|
starting_block: u16,
|
|
) -> Vec<(u16, usize)> {
|
|
let mut out = Vec::new();
|
|
let mut o = offset;
|
|
let mut b = starting_block;
|
|
for _ in 0..window {
|
|
if o >= total {
|
|
break;
|
|
}
|
|
let end = (o + blksize).min(total);
|
|
out.push((b, end - o));
|
|
o = end;
|
|
b = b.wrapping_add(1);
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_rrq_with_options() {
|
|
// RRQ "snponly.efi" mode "octet" blksize=1468 tsize=0
|
|
let mut pkt = vec![0, OP_RRQ as u8];
|
|
// The single-digit `\0` escapes here are NUL terminators between
|
|
// TFTP option name/value pairs — using `\x00` to dodge clippy's
|
|
// "octal-looking escape" lint.
|
|
pkt.extend_from_slice(b"snponly.efi\x00octet\x00blksize\x001468\x00tsize\x000\x00");
|
|
let r = parse_rrq(&pkt).unwrap();
|
|
assert_eq!(r.filename, "snponly.efi");
|
|
assert_eq!(r.mode, "octet");
|
|
assert_eq!(r.options.len(), 2);
|
|
assert_eq!(r.options[0].0, "blksize");
|
|
assert_eq!(r.options[0].1, "1468");
|
|
}
|
|
|
|
#[test]
|
|
fn encode_decode_data() {
|
|
let p = encode_data(7, b"hello");
|
|
assert_eq!(&p[0..2], &OP_DATA.to_be_bytes());
|
|
assert_eq!(&p[2..4], &7u16.to_be_bytes());
|
|
assert_eq!(&p[4..], b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_window_full_blocks() {
|
|
// 4 KB file, 1024 blksize, window 4 → one window of 4 full blocks.
|
|
let p = plan_window(4096, 0, 1024, 4, 1);
|
|
assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 1024)]);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_window_short_tail_at_eof() {
|
|
// 3.5 KB file, 1024 blksize, window 8 starting at offset 0.
|
|
// Expect 3 full + 1 half, then stop (below 8 blocks).
|
|
let p = plan_window(3584, 0, 1024, 8, 1);
|
|
assert_eq!(p, vec![(1, 1024), (2, 1024), (3, 1024), (4, 512)]);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_window_exact_boundary_needs_zero_final() {
|
|
// 2 KB file, 1024 blksize, window 8 — last block is exactly blksize.
|
|
// `handle_rrq` checks `last_chunk_len == blksize` to decide whether to
|
|
// emit the terminating zero-length DATA. Assert that condition here.
|
|
let p = plan_window(2048, 0, 1024, 8, 1);
|
|
assert_eq!(p, vec![(1, 1024), (2, 1024)]);
|
|
let last = p.last().unwrap();
|
|
assert_eq!(last.1, 1024); // => needs zero final per RFC 1350
|
|
}
|
|
|
|
#[test]
|
|
fn plan_window_wraparound() {
|
|
// Block number wraps from u16::MAX to 0 on next window — 2 blocks,
|
|
// starting at 65534.
|
|
let p = plan_window(2048, 0, 1024, 2, 65534);
|
|
assert_eq!(p, vec![(65534, 1024), (65535, 1024)]);
|
|
let p2 = plan_window(2048, 2048, 1024, 2, 0);
|
|
assert!(p2.is_empty()); // nothing past EOF
|
|
// And a cross-boundary case:
|
|
let p3 = plan_window(3072, 0, 1024, 3, 65535);
|
|
assert_eq!(p3, vec![(65535, 1024), (0, 1024), (1, 1024)]);
|
|
}
|
|
}
|