Skip to content

Commit bee7e43

Browse files
committed
Floor zero negotiated I/O sizes + tighten read decoder bounds (v0.5.5)
Addresses the two unresolved Copilot threads from #23, which auto-merge landed before they could be applied. Floor zero values from the SMB negotiate response. If a server (or a config edge case) returns max_read_size, max_write_size, or max_transact_size = 0, every downstream pipeline call does `remaining.div_ceil(0)` and panics the request task. The panic shows up to clients as "connection refused" on subsequent requests since spiceio's HTTP server is in the same process — exactly the failure mode behind the reported sccache flake against a long-running spiceio. negotiate_and_auth now substitutes the configured io_cap (default 64 KiB) for any zero, and logs at error level so the bug is visible. Two further safety nets: - pipelined_read returns an InvalidInput error if called with chunk_size = 0, so any future caller that skipped the floor still can't reach the div_ceil panic. - handle_get_object short-circuits to a 500 InternalError if handle.max_chunk somehow regressed to 0, instead of spawning a task that panics on the first read_pipeline call. Tighten decode_read_response_from_msg. The earlier rejection of `data_offset < SMB2_HEADER_SIZE` only ruled out offsets pointing at the SMB2 header — but the read response has 16 bytes of fixed fields (StructureSize/DataOffset/Reserved/DataLength/DataRemaining/Flags) before the Buffer. A malformed offset of e.g. SMB2_HEADER_SIZE + 4 would slice into DataLength and return those bytes as the file payload. The decoder now rejects anything before SMB2_HEADER_SIZE + 16 (a new exported READ_RESPONSE_FIXED_PART constant). Two new unit tests cover the response-fixed-fields band and the minimum-valid-offset boundary. Bumps version to v0.5.5.
1 parent e31c63c commit bee7e43

5 files changed

Lines changed: 104 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "spiceio"
3-
version = "0.5.4"
3+
version = "0.5.5"
44
edition = "2024"
55
description = "S3-compatible API proxy to SMB file shares"
66
license = "Apache-2.0"

src/s3/router.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,18 @@ async fn handle_get_object(
621621
// At default 64 KiB chunks the channel stays at the full pipeline depth
622622
// (4 MiB); at 1 MiB chunks it falls to 8 (still room to overlap).
623623
let chunk_size = handle.max_chunk;
624+
// Defense in depth: the SMB client floors any zero values from the
625+
// server's negotiate response, but if that floor ever regresses we'd
626+
// rather return a 500 here than panic the streaming task inside
627+
// `handle.read_pipeline(...)` on `remaining.div_ceil(0)`.
628+
if chunk_size == 0 {
629+
let _ = handle.close().await;
630+
return error_response(
631+
StatusCode::INTERNAL_SERVER_ERROR,
632+
"InternalError",
633+
"SMB server negotiated max_read_size = 0",
634+
);
635+
}
624636
let channel_cap = stream_channel_capacity(chunk_size);
625637
let (body, tx) = SpiceioBody::channel(channel_cap);
626638

src/smb/client.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,25 @@ impl SmbClient {
362362
} else {
363363
DEFAULT_MAX_IO
364364
};
365-
self.max_read_size = neg_resp.max_read_size.min(transact).min(io_cap);
366-
self.max_write_size = neg_resp.max_write_size.min(transact).min(io_cap);
365+
// Substitute io_cap for any negotiated value that's 0. A buggy or
366+
// misconfigured server can advertise max_read_size/max_write_size/
367+
// max_transact_size = 0; if we let that propagate, every downstream
368+
// pipeline call ends up doing `remaining.div_ceil(0)` and panics the
369+
// request task — that's the failure mode behind sccache "Connection
370+
// refused" against a long-running spiceio.
371+
let nonzero = |v: u32, name: &str| -> u32 {
372+
if v == 0 {
373+
crate::serr!("[spiceio] smb negotiated {name}=0 — substituting io_cap={io_cap}");
374+
io_cap
375+
} else {
376+
v
377+
}
378+
};
379+
let neg_read = nonzero(neg_resp.max_read_size, "max_read_size");
380+
let neg_write = nonzero(neg_resp.max_write_size, "max_write_size");
381+
let neg_transact = nonzero(transact, "max_transact_size");
382+
self.max_read_size = neg_read.min(neg_transact).min(io_cap);
383+
self.max_write_size = neg_write.min(neg_transact).min(io_cap);
367384
// Cap at 64KB for compound requests — some NAS servers reject larger
368385
// payloads inside compound (chained) operations.
369386
self.compound_max_read_size = self.max_read_size.min(65536);
@@ -527,6 +544,17 @@ impl SmbClient {
527544
if count == 0 {
528545
return Ok(Vec::new());
529546
}
547+
// Defensive guard: a zero `chunk_size` would panic later on
548+
// `remaining.div_ceil(chunk_size as u64)` in callers, and would
549+
// make this function issue `count` duplicate reads at the same
550+
// offset. Callers are expected to have validated already, but
551+
// we treat this as a hard error rather than UB.
552+
if chunk_size == 0 {
553+
return Err(io::Error::new(
554+
io::ErrorKind::InvalidInput,
555+
"pipelined_read called with chunk_size = 0",
556+
));
557+
}
530558

531559
// Allocate message IDs in a contiguous batch so we can map
532560
// response.message_id → slot index via simple subtraction.

src/smb/protocol.rs

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -493,21 +493,27 @@ pub fn decode_read_response_owned(body: Vec<u8>) -> Option<Bytes> {
493493
/// caller had to split body off first.
494494
///
495495
/// Treats `data_offset` as an absolute offset from the start of the SMB2
496-
/// message and rejects offsets that fall inside the 64-byte header, so a
497-
/// malformed server response cannot trick us into returning header bytes as
498-
/// the read payload.
496+
/// message and rejects offsets that fall inside the 64-byte header *or*
497+
/// inside the 16 bytes of fixed read-response fields that precede the data
498+
/// buffer. A malformed server response with an offset short of
499+
/// `SMB2_HEADER_SIZE + 16` would otherwise let us return header bytes — or
500+
/// the response's own StructureSize/DataOffset/DataLength/etc — as the file
501+
/// payload.
499502
pub fn decode_read_response_from_msg(msg: Vec<u8>) -> Option<Bytes> {
500-
if msg.len() < SMB2_HEADER_SIZE + 17 {
503+
if msg.len() < SMB2_HEADER_SIZE + READ_RESPONSE_FIXED_PART {
501504
return None;
502505
}
503506
let body = &msg[SMB2_HEADER_SIZE..];
504507
let data_offset = u16::from_le_bytes(body[2..4].try_into().unwrap()) as usize;
505508
let data_length = u32::from_le_bytes(body[4..8].try_into().unwrap()) as usize;
506509

507-
// `data_offset` is from the start of the SMB2 message. It must not point
508-
// inside the header (or before it) — a malformed server response with an
509-
// offset < SMB2_HEADER_SIZE would otherwise slice into header bytes.
510-
if data_offset < SMB2_HEADER_SIZE {
510+
// `data_offset` is from the start of the SMB2 message. The minimum
511+
// legitimate value points at the first byte of the read response's
512+
// Buffer field — anything before that overlaps the SMB2 header or the
513+
// read response's fixed fields (StructureSize, DataOffset, Reserved,
514+
// DataLength, DataRemaining, Flags = 16 bytes).
515+
let min_offset = SMB2_HEADER_SIZE + READ_RESPONSE_FIXED_PART;
516+
if data_offset < min_offset {
511517
return None;
512518
}
513519
let start = data_offset;
@@ -519,6 +525,11 @@ pub fn decode_read_response_from_msg(msg: Vec<u8>) -> Option<Bytes> {
519525
Some(bytes.slice(start..end))
520526
}
521527

528+
/// Size of the read response's fixed fields (preceding the Buffer):
529+
/// StructureSize(2) + DataOffset(1) + Reserved(1) + DataLength(4)
530+
/// + DataRemaining(4) + Flags(4) = 16 bytes.
531+
pub const READ_RESPONSE_FIXED_PART: usize = 16;
532+
522533
// ── Write ───────────────────────────────────────────────────────────────────
523534

524535
pub fn encode_write_request(buf: &mut BytesMut, file_id: &[u8; 16], offset: u64, data: &[u8]) {
@@ -964,6 +975,47 @@ mod tests {
964975
assert!(decode_read_response_from_msg(msg).is_none());
965976
}
966977

978+
#[test]
979+
fn decode_read_response_from_msg_rejects_offset_in_response_fixed_fields() {
980+
// data_offset that points inside the read response's fixed fields
981+
// (StructureSize/DataOffset/Reserved/DataLength/DataRemaining/Flags
982+
// — 16 bytes after the SMB2 header) would otherwise leak the
983+
// response's own structural fields back as the file payload.
984+
let mut msg = vec![0u8; SMB2_HEADER_SIZE + 32];
985+
let body = &mut msg[SMB2_HEADER_SIZE..];
986+
// Offset = SMB2_HEADER_SIZE + 4 — points inside DataLength.
987+
let bad_offset = (SMB2_HEADER_SIZE + 4) as u16;
988+
body[2..4].copy_from_slice(&bad_offset.to_le_bytes());
989+
body[4..8].copy_from_slice(&4u32.to_le_bytes());
990+
assert!(decode_read_response_from_msg(msg).is_none());
991+
992+
// Offset = SMB2_HEADER_SIZE + 15 — one byte short of the buffer.
993+
let mut msg = vec![0u8; SMB2_HEADER_SIZE + 32];
994+
let body = &mut msg[SMB2_HEADER_SIZE..];
995+
let bad_offset = (SMB2_HEADER_SIZE + 15) as u16;
996+
body[2..4].copy_from_slice(&bad_offset.to_le_bytes());
997+
body[4..8].copy_from_slice(&4u32.to_le_bytes());
998+
assert!(decode_read_response_from_msg(msg).is_none());
999+
}
1000+
1001+
#[test]
1002+
fn decode_read_response_from_msg_accepts_minimum_valid_offset() {
1003+
// SMB2_HEADER_SIZE + 16 is the first byte of the Buffer field —
1004+
// the smallest legitimate data_offset. Make sure we don't
1005+
// over-reject the boundary.
1006+
let payload = b"data";
1007+
let mut msg = vec![0u8; SMB2_HEADER_SIZE + READ_RESPONSE_FIXED_PART + payload.len()];
1008+
let body = &mut msg[SMB2_HEADER_SIZE..];
1009+
let min_offset = (SMB2_HEADER_SIZE + READ_RESPONSE_FIXED_PART) as u16;
1010+
body[2..4].copy_from_slice(&min_offset.to_le_bytes());
1011+
body[4..8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1012+
let buf_start = SMB2_HEADER_SIZE + READ_RESPONSE_FIXED_PART;
1013+
msg[buf_start..buf_start + payload.len()].copy_from_slice(payload);
1014+
1015+
let data = decode_read_response_from_msg(msg).unwrap();
1016+
assert_eq!(&data[..], payload);
1017+
}
1018+
9671019
#[test]
9681020
fn decode_write_response_valid() {
9691021
let mut body = vec![0u8; 16];

0 commit comments

Comments
 (0)