Initial commit: PXEForge Phases 1-4
Container-native PXE boot server in Rust, designed as a clean-room alternative to iVentoy that never touches the client OS trust store. This is the first commit of the project; it lands the full output of Phases 1, 2, 3, and 4 in one shot. ## Phase 1 — protocol stack - 8-crate workspace (core, dhcp-proxy, tftp, http-api, iso-store, ipxe-assets, webui, pxeforge bin). - DHCP proxy (RFC 4578): replies with boot info only, never leases — sidesteps CAP_NET_RAW. Architecture-aware bootfile selection from option 93 (BIOS, IA32, x64-UEFI alias 0x0007/0x0009, ARM64). - TFTP server with full OACK negotiation: blksize, tsize, windowsize. Without it a 1 MiB iPXE binary takes 2000 packets and unusably long. - Two-stage iPXE chain: firmware PXE -> TFTP iPXE binary -> iPXE re-DHCPs with user-class iPXE -> HTTP /boot.ipxe -> kernel+initrd. - HTTP server (axum) with byte-Range ISO streaming and an in-place ISO9660 lookup so kernel/initrd are served from inside the ISO without ever extracting it to disk. - Linux ISOs boot via kernel+initrd extraction (memdisk/sanboot fail for >1-2 GiB modern distros). Distro-family detection drives the cmdline (Debian/Ubuntu, RHEL/Fedora, openSUSE, Arch, Alpine). ## Phase 2 — UX + Windows - Hierarchical PXE menu (Default / Installers / Tools / Gated Deployment) generated from settings — no hand-written .ipxe paths surface in the UI. Number-key + letter hotkeys, BIOS+UEFI variants for some RHEL ISOs. - Gated Deployment "horse-race" queue: clients join, operator picks one ISO, every gate launches simultaneously via tokio::sync::Notify. - Bootimus-pattern Windows: WimPatcher injects a CRLF startnet.cmd into boot.wim so vanilla WinPE net-uses an SMB share and runs setup.exe. All Microsoft-signed; no test certs, no testsigning, no httpdisk.sys. SmbManager supervises smbd start/stop/SIGHUP. - Netbox-style dark UI, fully offline (no CDN, no external fonts). ## Phase 3 — MVP hardening - TFTP retransmit rewrite with explicit window tracking — UEFI SNP clients no longer hang on files that end mid-window. 4 new tests. - DHCP broadcast-flag honored per RFC 2131 §4.1. - Multi-arch container (linux/amd64 + linux/arm64). Entrypoint chowns bind-mounts as root then drops to uid 10001 via gosu. - /healthz + /readyz split from /api/status — readyz fails if no iPXE binaries are bundled. - pxeforge seed --from <path> CLI: same pipeline as web upload (slug, sha256, introspection, boot-entry). - All timestamps RFC 3339 (browser Date couldn't parse the 9-tuple). - Gate poll retains assignment until operator releases — clients that retry on transient network errors reuse the assignment instead of falling back to the menu. - Custom OpenShift SCC: hostNetwork + NET_BIND_SERVICE only, no NET_RAW. ## Phase 4 — UI restructure + remote storage - Web UI rebuilt around six tabs inspired by the iVentoy layout: Dashboard / Network / Forge Gate / Storage / Terminal / About. Old "Monitoring/Content/Configuration" sidebar groups are gone. - NFS share manager (crates/iso-store/src/nfs.rs): mount NFSv3 or NFSv4.1 shares as ISO sources instead of uploading every file into the PVC. New IsoSource enum on IsoMeta lets the store resolve Local vs NFS lazily. Persisted to <work_dir>/nfs.json; failed mounts surface in the UI rather than blocking startup. - Dockerfile gains nfs-common + iproute2; mounting NFS in-container also requires CAP_SYS_ADMIN. Documented in docs/architecture.md. - LogBus + tracing layer in core: 500-line ring buffer + broadcast channel feed an SSE endpoint at /api/log/stream. - Operator terminal at /api/terminal: whitelisted commands (status, isos, clients, gate, nfs, smb, log) — deliberately not a shell. Output mirrored onto the LogBus so the live tail and the terminal pane share one timeline. - Network tab: read-only nic_name / subnet_mask / gateway probed from `ip` at startup; only DNS server is editable. Editing IP/mask on a hot UI would silently break PXE for every client mid-boot. - Bootimus parity (releases v0.1.55 -> v0.1.62): amber row tint on un-bootable ISOs with inline reasons, dashboard "won't boot" panel. ## Tests 56 tests passing across the workspace: - 16 core (LogBus, gate, settings, arch, client) - 1 dhcp-proxy (raw option-93 extraction) - 8 http-api unit (range parsing, terminal split/format) - 13 http-api integration (gated deployment, range, settings, NFS, terminal, log SSE, network endpoint, ui assets, no-external-urls) - 12 iso-store (introspect, slugify, smb, windows wim, NFS options) - 6 tftp (RRQ parsing, plan_window edges) cargo build --workspace and cargo clippy --workspace --all-targets both finish clean (warnings only, no errors).
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "pxeforge-tftp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "TFTP server (RFC 1350/2347/2348/2349/7440) for iPXE chainload"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pxeforge-core.workspace = true
|
||||
pxeforge-ipxe-assets.workspace = true
|
||||
tokio.workspace = true
|
||||
socket2.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Minimal TFTP server sufficient to deliver iPXE binaries (~1 MiB each)
|
||||
//! to firmware PXE ROMs. Only implements RRQ (read requests) because we
|
||||
//! never receive WRQs in our use case.
|
||||
//!
|
||||
//! Implements RFC 1350 base protocol plus:
|
||||
//! - RFC 2347 option negotiation (OACK)
|
||||
//! - RFC 2348 `blksize` (critical — default 512 makes transfers unusably slow)
|
||||
//! - RFC 2349 `tsize` (some PXE ROMs require it)
|
||||
//! - RFC 7440 `windowsize` (huge throughput improvement for supporting clients)
|
||||
//!
|
||||
//! Files served are backed by the embedded iPXE asset store; there is no
|
||||
//! filesystem path traversal surface because we only look up by asset name.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod server;
|
||||
|
||||
pub use server::TftpServer;
|
||||
@@ -0,0 +1,443 @@
|
||||
//! 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 `pxeforge_ipxe_assets::asset_bytes` — that is,
|
||||
//! the bundled iPXE binaries and wimboot. No filesystem is ever opened, so
|
||||
//! `../` path traversal attempts simply return ENOENT.
|
||||
|
||||
use pxeforge_core::{ClientEvent, ClientRegistry};
|
||||
use pxeforge_ipxe_assets::asset_bytes;
|
||||
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_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;
|
||||
|
||||
pub struct TftpServer {
|
||||
bind: IpAddr,
|
||||
port: u16,
|
||||
clients: Arc<ClientRegistry>,
|
||||
}
|
||||
|
||||
impl TftpServer {
|
||||
pub fn new(bind: IpAddr, port: u16, clients: Arc<ClientRegistry>) -> Self {
|
||||
Self { bind, port, clients }
|
||||
}
|
||||
|
||||
pub async fn run(self) -> anyhow::Result<()> {
|
||||
let sock = bind_udp(self.bind, self.port)?;
|
||||
tracing::info!(target: "pxeforge::tftp", "TFTP listening on {}:{}", self.bind, self.port);
|
||||
let clients = self.clients.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: "pxeforge::tftp", "recv error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let data = buf[..n].to_vec();
|
||||
let clients = clients.clone();
|
||||
let bind_ip = self.bind;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_rrq(data, from, bind_ip, clients).await {
|
||||
tracing::warn!(target: "pxeforge::tftp", peer=%from, "handler error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_rrq(
|
||||
packet: Vec<u8>,
|
||||
peer: SocketAddr,
|
||||
bind_ip: IpAddr,
|
||||
clients: Arc<ClientRegistry>,
|
||||
) -> anyhow::Result<()> {
|
||||
let req = match parse_rrq(&packet) {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let Request { filename, options, .. } = req;
|
||||
|
||||
// Per-transfer ephemeral socket.
|
||||
let sock = bind_udp(bind_ip, 0)?;
|
||||
|
||||
let Some(file_bytes) = asset_bytes(&filename) else {
|
||||
let _ = send_error(&sock, peer, ERR_FILE_NOT_FOUND, "no such file").await;
|
||||
tracing::info!(target: "pxeforge::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: "pxeforge::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;
|
||||
let mut needs_zero_final = false; // spec: if last data block == blksize, follow with empty DATA
|
||||
|
||||
'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(_)) => continue, // stale ACK from an earlier block — ignore
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
tries += 1;
|
||||
if tries > 5 {
|
||||
tracing::warn!(
|
||||
target: "pxeforge::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: "pxeforge::tftp", peer=%peer, bytes=total, "transfer complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Request {
|
||||
filename: String,
|
||||
#[allow(dead_code)]
|
||||
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 k = match read_cstr(&mut rest) { Some(s) => s, None => 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}");
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(_)) => continue,
|
||||
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)?)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
const _UNUSED: (u16, u16) = (ERR_NOT_DEFINED, ERR_ILLEGAL_OP);
|
||||
|
||||
/// Pure-logic helper used by the unit tests below and (in a refactor) by
|
||||
/// `handle_rrq`. Given a position in the file and the window, return the
|
||||
/// (block_no, chunk_len) list this window will emit. Useful as a sanity
|
||||
/// check that our windowing math matches the wire behavior the spec
|
||||
/// requires — tested against edge cases (exact-blksize tail, short tail,
|
||||
/// single-block window).
|
||||
#[must_use]
|
||||
pub 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];
|
||||
pkt.extend_from_slice(b"snponly.efi\0octet\0blksize\01468\0tsize\00\0");
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user