Add SMB READ_RAW support for faster bulk transfers (#366)

Win95's redirector was falling back to ~2.8KB core READs (no raw mode
advertised), so a 200KB copy took ~360 round-trips through the emulated
NIC/TCP stack (~10s). With READ_RAW it pulls up to 64KB per round-trip.

- server.ts: readRaw() handler (raw bytes only, 0-byte frame on error);
  negotiate now sets MaxRawSize=65535 + CAP_RAW_MODE (NT) and RawMode
  bit 0 (LM); per-packet hex capture gated on WIN95_SMB_CAPTURE
- smb.ts: CMD_READ_RAW constant
- test-standalone.ts: READ_RAW happy-path + bad-fid tests (57 pass)
This commit is contained in:
Felix Rieseberg
2026-04-12 17:52:17 -07:00
committed by GitHub
parent fff371073d
commit a1637b1de1
3 changed files with 36 additions and 7 deletions

View File

@@ -8,7 +8,7 @@ import {
parseSmb, buildSmb, dosError, andxNone, cmdName, SmbHeader,
CMD_NEGOTIATE, CMD_SESSION_SETUP_ANDX, CMD_TREE_CONNECT_ANDX,
CMD_TREE_DISCONNECT, CMD_LOGOFF_ANDX, CMD_NT_CREATE_ANDX, CMD_OPEN_ANDX,
CMD_READ, CMD_READ_ANDX, CMD_SEEK, CMD_CLOSE, CMD_TRANSACTION, CMD_TRANSACTION2, CMD_ECHO,
CMD_READ, CMD_READ_RAW, CMD_READ_ANDX, CMD_SEEK, CMD_CLOSE, CMD_TRANSACTION, CMD_TRANSACTION2, CMD_ECHO,
CMD_QUERY_INFORMATION, CMD_FIND_CLOSE2, CMD_CHECK_DIRECTORY, CMD_SEARCH,
TRANS2_FIND_FIRST2, TRANS2_FIND_NEXT2, TRANS2_QUERY_FS_INFO, TRANS2_QUERY_PATH_INFO,
ERRDOS, ERRSRV, ERR_BADFILE, ERR_BADPATH, ERR_BADFID, ERR_NOFILES, ERR_BADFUNC,
@@ -112,7 +112,7 @@ export class SmbSession {
private readonly realRoot: string;
private readonly toolsRoot?: string;
public readonly shareName: string;
public capture = true;
public capture = !!process.env.WIN95_SMB_CAPTURE;
// Synthetic files served at the share root. They show up in directory
// listings and OPEN/READ work, but they don't exist on the host fs —
@@ -241,6 +241,10 @@ export class SmbSession {
case CMD_NT_CREATE_ANDX: return this.ntCreate(req);
case CMD_OPEN_ANDX: return this.openAndx(req);
case CMD_READ: return this.coreRead(req);
// READ_RAW reply has no SMB header — handle outside the generic
// catch so an fs error becomes a 0-byte frame, not garbage data.
case CMD_READ_RAW:
try { return this.readRaw(req); } catch { return new Uint8Array(0); }
case CMD_READ_ANDX: return this.read(req);
case CMD_SEEK: return this.seek(req);
case CMD_CLOSE: return this.close(req);
@@ -304,9 +308,9 @@ export class SmbSession {
.u16(1) // MaxMpxCount
.u16(1) // MaxNumberVcs
.u32(16384) // MaxBufferSize
.u32(0) // MaxRawSize (no raw)
.u32(65535) // MaxRawSize
.u32(0) // SessionKey
.u32(0) // Capabilities
.u32(0x00000001) // Capabilities: CAP_RAW_MODE only
.u64(0) // SystemTime (FILETIME — Win95 ignores 0)
.u16(0) // ServerTimeZone
.u8(0) // ChallengeLength = 0
@@ -324,7 +328,7 @@ export class SmbSession {
.u16(16384) // MaxBufferSize
.u16(1) // MaxMpxCount
.u16(1) // MaxNumberVcs
.u16(0) // RawMode (none)
.u16(0x0001) // RawMode: read-raw supported
.u32(0) // SessionKey
.u16(0) // ServerTime (we cheat — Win95 doesn't care)
.u16(0) // ServerDate
@@ -670,16 +674,28 @@ export class SmbSession {
return buildSmb(req, CMD_READ, 0, words, bytes);
}
private readBytes(fid: number, offset: number, count: number): Uint8Array | null {
private readBytes(fid: number, offset: number, count: number, cap = 16384): Uint8Array | null {
const file = this.fids.get(fid);
if (!file || file.isDir) return null;
const want = Math.min(count, 16384, Math.max(0, file.size - offset));
const want = Math.min(count, cap, Math.max(0, file.size - offset));
if (file.virtual) return file.virtual.slice(offset, offset + want);
const buf = Buffer.alloc(want);
const n = want > 0 ? fs.readSync(file.fd, buf, 0, want, offset) : 0;
return buf.subarray(0, n);
}
// READ_RAW (0x1a): Win95's bulk-transfer path. The response is *not* an SMB
// message — just the raw file bytes inside a NetBIOS frame, length implied
// by the NB header. On error/EOF we send zero bytes and the client falls
// back to a normal READ to get the actual error code.
private readRaw(req: SmbHeader): Uint8Array {
const wr = new Reader(req.words);
const fid = wr.u16();
const offset = wr.u32();
const maxCount = wr.u16();
return this.readBytes(fid, offset, maxCount, 65535) ?? new Uint8Array(0);
}
// SEEK (0x12): legacy lseek. READ_ANDX carries an explicit offset so we
// don't need a real cursor — but Win95 (Notepad in particular) opens,
// SEEKs to end-of-file with mode=2 offset=0 to learn the size, then

View File

@@ -17,6 +17,7 @@ export const CMD_LOGOFF_ANDX = 0x74;
export const CMD_NT_CREATE_ANDX = 0xa2;
export const CMD_OPEN_ANDX = 0x2d;
export const CMD_READ = 0x0a;
export const CMD_READ_RAW = 0x1a;
export const CMD_READ_ANDX = 0x2e;
export const CMD_SEEK = 0x12;
export const CMD_CLOSE = 0x04;
@@ -145,6 +146,7 @@ export const cmdName: Record<number, string> = {
[CMD_NT_CREATE_ANDX]: "NT_CREATE",
[CMD_OPEN_ANDX]: "OPEN",
[CMD_READ_ANDX]: "READ",
[CMD_READ_RAW]: "READ_RAW",
[CMD_READ]: "READ",
[CMD_SEEK]: "SEEK",
[CMD_CLOSE]: "CLOSE",

View File

@@ -411,6 +411,17 @@ console.log("\n[7] Error handling");
const body = String.fromCharCode(...rP.bytes.slice(3, 3 + dlen));
ok(dlen === seekPos, `core READ returned ${dlen} bytes`);
ok(body.includes("windows95 tools"), `README content: ${JSON.stringify(body.slice(0, 30))}`);
// READ_RAW (0x1a): response is raw bytes, no SMB header.
const raw = session.handle(smbReq(0x1a,
[...u16(fid2), ...u32(0), ...u16(65535), ...u16(0), ...u32(0), ...u16(0)],
[], tcParsed.tid, 1))!;
ok(raw.length === seekPos && raw[0] === 0x77 /* 'w' */,
`READ_RAW returned ${raw.length} raw bytes (no SMB header)`);
const rawBad = session.handle(smbReq(0x1a,
[...u16(0x7777), ...u32(0), ...u16(100), ...u16(0), ...u32(0), ...u16(0)],
[], tcParsed.tid, 1))!;
ok(rawBad.length === 0, "READ_RAW bad fid → 0-byte reply");
}
{
// symlink escape: link inside share → file outside share