SMB: long filenames, basename-derived share name, synthetic TOOLS share (#352)

Negotiate NT LM 0.12 (17-word response, Capabilities=0) so Win95 switches
from CMD_SEARCH (8.3-only) to TRANS2/FIND_FIRST2. Implement info level
0x104 (FILE_BOTH_DIRECTORY_INFO) with the win9x quirks Samba carries:
honor SearchCount/MaxDataCount (VREDIR drops the session if exceeded),
4-byte-align entries and the trans2 reply param/data blocks, dir
EndOfFile=0, FileName null-terminated with the null counted in
FileNameLength, and ShortNameLength=0 (a UCS-2 ShortName in an OEM
session GPFs shell32 on the single-directory probe Explorer does when
entering a subfolder). Add TRANS2_QUERY_FS_INFO (0x105/1/2), SMB_COM_SEEK
and core SMB_COM_READ, which the redirector falls back to with no
CAP_LARGE_READX. Sanitize >0xFF / Windows-reserved chars in display names
so emoji folder names don't truncate to '<' and wedge Explorer.

The user share is now named after path.basename of the mounted folder
(LANMAN-safe, ≤12 chars, with TOOLS/IPC$ collision avoided). A second
purely-synthetic TOOLS share holds _MAPZ.BAT and README.TXT so they
don't clutter the user's directory; treeConnect routes by share name to
a TID and every path-resolving handler branches on it so TOOLS never
touches the host fs.

48 protocol tests; verified end-to-end in the emulator (browse \\HOST,
open both shares, list Downloads with 85+ mixed-name entries, navigate
subfolders, open files in Notepad).
This commit is contained in:
Felix Rieseberg
2026-04-11 12:26:54 -07:00
committed by GitHub
parent 43c025929b
commit 148f8e4874
8 changed files with 570 additions and 134 deletions

View File

@@ -8,7 +8,7 @@ export interface CardStartProps {
const TIPS = [
"Press the Escape key at any time to release or recapture your mouse cursor.",
"You can mount a floppy image from Settings before booting to install vintage software.",
"Map a host folder as a network drive: open Start → Run inside Windows and type \\\\HOST\\HOST.",
"Map a host folder as a network drive: open Start → Run inside Windows and type \\\\HOST.",
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
];

View File

@@ -4,8 +4,8 @@
import * as fs from "fs";
const STATUS_FILE = "/tmp/win95-probe.json";
const SCREEN_FILE = "/tmp/win95-screen.png";
const STATUS_FILE = process.env.WIN95_PROBE_STATUS || "/tmp/win95-probe.json";
const SCREEN_FILE = process.env.WIN95_PROBE_SCREEN || "/tmp/win95-screen.png";
const TICK_MS = 5000;
interface ProbeStatus {

View File

@@ -16,12 +16,24 @@ a host folder as a network drive. Read-only. ~1500 lines.
## Protocol gotchas (learned the hard way)
### NEGOTIATE: don't pick NT LM 0.12 unless you implement the NT response
### NEGOTIATE: NT LM 0.12 is the only path to long filenames
Win95 offers `["PC NETWORK PROGRAM 1.0", "MICROSOFT NETWORKS 3.0", "DOS LM1.2X002",
"DOS LANMAN2.1", "Windows for Workgroups 3.1a", "NT LM 0.12"]`. We send the
13-word LANMAN-style negotiate response. If you pick `NT LM 0.12` and send 13
words, Win95 silently drops the connection — it expects the 17-word NT response
with capability flags. Pick `DOS LANMAN2.1` instead.
"DOS LANMAN2.1", "Windows for Workgroups 3.1a", "NT LM 0.12"]`. We pick
`NT LM 0.12` and send the 17-word NT response (Capabilities=0 — no UNICODE, no
NT_STATUS, no NT_FIND, so the rest of the protocol stays OEM/DOS-error). On any
LANMAN dialect Win95's redirector lists directories via `CMD_SEARCH` (0x81) whose
13-byte name field hard-caps at 8.3; under NT LM 0.12 it switches to
`TRANS2/FIND_FIRST2` and asks for level `0x104` (FILE_BOTH_DIRECTORY_INFO)
**regardless** of CAP_NT_FIND. We implement that level — the 94-byte fixed prefix
plus OEM long name, ShortName always UTF-16LE per spec. The 13-word LANMAN
response is kept as a fallback for clients that don't offer NT.
### Shares
Two disk shares plus IPC$. The user share is named after `path.basename()` of the
mounted folder (sanitized, ≤12 chars). `TOOLS` is purely synthetic — `_MAPZ.BAT`,
`README.TXT` — so the user's listing isn't cluttered. `treeConnect` routes by
share name to a TID; every path-resolving handler branches on TID so the TOOLS
tree never touches the host fs.
### SEARCH (0x81): single-file probes vs wildcard listings
`SEARCH "\FOO.TXT"` is a stat probe — Win95 wants exactly one entry back. If you
@@ -85,7 +97,7 @@ Clean API. The new code keeps both paths; the bus event is a no-op on old builds
- Share path validated in main-process IPC (`realpathSync` + `isDirectory()`).
## Tests
`test-standalone.ts`35 protocol tests, full round-trips with real file I/O.
Run: `npx tsc --ignoreConfig --module commonjs --target es2020 --esModuleInterop
--moduleResolution bundler --outDir /tmp/smb-test --skipLibCheck
src/renderer/smb/*.ts && node /tmp/smb-test/test-standalone.js`
`test-standalone.ts`48 protocol tests, full round-trips with real file I/O.
Run: `npx ts-node --skip-project --transpile-only --compiler-options
'{"module":"commonjs","moduleResolution":"bundler","ignoreDeprecations":"6.0"}'
src/renderer/smb/test-standalone.ts`

View File

@@ -12,10 +12,10 @@ import * as os from "os";
import * as path from "path";
import { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
import { setupNbns } from "./nbns";
import { SmbSession } from "./server";
import { SmbSession, shareNameFor, TOOLS_SHARE } from "./server";
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
const LOG_FILE = path.join(os.tmpdir(), "windows95-smb.log");
const LOG_FILE = process.env.WIN95_SMB_LOG || path.join(os.tmpdir(), "windows95-smb.log");
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
const origLog = console.log;
console.log = (...args: unknown[]) => {
@@ -57,7 +57,8 @@ interface V86 {
const log = (...a: unknown[]) => console.log("[smb]", ...a);
export function setupSmbShare(emulator: V86, hostPath: string) {
log(`serving ${hostPath} on \\\\HOST\\host (port 139)`);
log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
`(+ \\\\HOST\\${TOOLS_SHARE}) port 139`);
// SPIKE diagnostic: count every ethernet frame so we know if the NIC is
// emitting anything at all (DHCP, ARP, anything). Logged on a timer so

View File

@@ -8,9 +8,9 @@ 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_ANDX, CMD_CLOSE, CMD_TRANSACTION, CMD_TRANSACTION2, CMD_ECHO,
CMD_READ, 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_PATH_INFO,
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,
} from "./smb";
@@ -19,11 +19,27 @@ const hex = (b: Uint8Array, n = 64) =>
Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, "0")).join(" ") +
(b.length > n ? ` …(+${b.length - n})` : "");
/**
* Derive the share name shown under \\HOST from the mounted directory's
* basename. NetShareEnum's B13 field caps it at 12 chars + null; SMB share
* names are case-insensitive OEM, so uppercase and strip to LANMAN-safe chars.
*/
export function shareNameFor(rootPath: string): string {
const base = path.basename(rootPath)
.replace(/[^A-Za-z0-9_$~!#%&'()@^`{}.-]/g, "")
.toUpperCase()
.slice(0, 12);
// A folder literally named "Tools" or "ipc$" would shadow a reserved share
// and become unreachable — fall back to the generic name instead.
return (!base || base === TOOLS_SHARE || base === "IPC$") ? "HOST" : base;
}
interface OpenFile {
hostPath: string;
fd: number;
size: number;
isDir: boolean;
pos?: number;
virtual?: Uint8Array;
}
@@ -41,11 +57,23 @@ interface SearchState {
const ATTR_DIRECTORY = 0x10;
const ATTR_ARCHIVE = 0x20;
// Tree IDs. TID routes every file op: SHARE = the user's mounted folder,
// TOOLS = a purely synthetic share holding _MAPZ.BAT and friends so they
// don't clutter the user's directory listing.
const TID_SHARE = 1;
const TID_TOOLS = 2;
const TID_IPC = 0xfffe;
export const TOOLS_SHARE = "TOOLS";
const NT_DIALECT = "NT LM 0.12";
const DIALECTS = [
// Our negotiate response uses the 13-word LANMAN format, so we MUST NOT
// pick "NT LM 0.12" — Win95 expects the 17-word NT response for that
// dialect and will silently drop a malformed reply. Win95's actual
// dialect strings have a "DOS " prefix (vs the bare names NT/2000 send).
// Prefer NT LM 0.12 — it's the only dialect where Win95's redirector
// switches to TRANS2/FIND_FIRST2 (long filenames). On LANMAN it falls back
// to CMD_SEARCH whose 13-byte name field hard-caps us at 8.3. The two
// dialects have different negotiate-response shapes (17-word NT vs 13-word
// LM), branched on below. Win95's strings have a "DOS " prefix vs the bare
// names NT/2000 send.
NT_DIALECT,
"DOS LANMAN2.1",
"LANMAN2.1",
"Windows for Workgroups 3.1a",
@@ -64,6 +92,7 @@ export class SmbSession {
// consult it so clicking "15UNDE~2.PDF" finds the right long-named file.
private sfnMaps = new Map<string, Map<string, string>>();
private readonly realRoot: string;
public readonly shareName: string;
public capture = true;
// Synthetic files served at the share root. They show up in directory
@@ -71,27 +100,49 @@ export class SmbSession {
// just in-memory bytes. _MAPZ.BAT maps the share to Z: when the user
// double-clicks it; copying it to C:\WINDOWS\Start Menu\Programs\StartUp
// makes the mapping survive reboots.
private readonly virtuals = new Map<string, Uint8Array>([
["_MAPZ.BAT", new TextEncoder().encode(
"@ECHO OFF\r\n" +
"NET USE Z: \\\\HOST\\HOST\r\n" +
"ECHO Share mapped to Z:\r\n" +
"ECHO Copy this file to C:\\WINDOWS\\STARTM~1\\PROGRAMS\\STARTUP\r\n" +
"ECHO to reconnect automatically on every boot.\r\n" +
"PAUSE\r\n"
)],
]);
private readonly virtuals: Map<string, Uint8Array>;
constructor(rootPath: string) {
this.realRoot = fs.realpathSync(rootPath);
this.shareName = shareNameFor(this.realRoot);
const enc = (s: string) => new TextEncoder().encode(s);
this.virtuals = new Map([
["README.TXT", enc(
"windows95 tools\r\n" +
"----------------\r\n" +
"These files are generated by the windows95 app and do not exist on\r\n" +
"your host disk.\r\n\r\n" +
` \\\\HOST\\${this.shareName.padEnd(12)} your shared folder (${this.realRoot})\r\n` +
` \\\\HOST\\${TOOLS_SHARE.padEnd(12)} this folder\r\n\r\n` +
"_MAPZ.BAT maps your shared folder to drive Z:. Copy it to\r\n" +
" C:\\WINDOWS\\Start Menu\\Programs\\StartUp to reconnect\r\n" +
" on every boot.\r\n"
)],
["_MAPZ.BAT", enc(
"@ECHO OFF\r\n" +
`NET USE Z: \\\\HOST\\${this.shareName}\r\n` +
"ECHO Share mapped to Z:\r\n" +
"ECHO Copy this file to C:\\WINDOWS\\STARTM~1\\PROGRAMS\\STARTUP\r\n" +
"ECHO to reconnect automatically on every boot.\r\n" +
"PAUSE\r\n"
)],
]);
}
private getVirtual(smbPath: string): Uint8Array | undefined {
private getVirtual(tid: number, smbPath: string): Uint8Array | undefined {
if (tid !== TID_TOOLS) return undefined;
const p = smbPath.replace(/^[\\\/]+/, "").replace(/\\/g, "/");
if (p.includes("/")) return undefined; // root-only
return this.virtuals.get(p.toUpperCase());
}
private listTools(): DirEntry[] {
const now = new Date();
const stat = (size: number) => ({ isDirectory: () => false, size, mtime: now });
return Array.from(this.virtuals, ([name, bytes]) =>
({ name, sfn: name, stat: stat(bytes.length) }));
}
/**
* Read a host directory once, generate stable 8.3 names, cache the mapping.
* The cache lives for the session — directory contents changing underneath
@@ -106,20 +157,16 @@ export class SmbSession {
const entries: DirEntry[] = [];
for (const [sfn, real] of sfnMap) {
try {
entries.push({ name: real, sfn, stat: fs.statSync(path.join(hostDir, real)) });
// The long name we send is single-byte OEM. Anything outside that
// (emoji, CJK) truncates to its low byte, which can land on a
// Windows-illegal char and wedge Explorer's icon renderer. Sanitize
// for display and add the sanitized form to the lookup map so OPEN
// on the displayed name still finds the real file.
const name = oemSafe(real);
if (name !== real) sfnMap.set(name.toUpperCase(), real);
entries.push({ name, sfn, stat: fs.statSync(path.join(hostDir, real)) });
} catch { /* raced — skip */ }
}
// Virtuals only at root. They're already 8.3.
if (hostDir === this.realRoot) {
const now = new Date();
for (const [name, bytes] of this.virtuals) {
entries.unshift({
name, sfn: name,
stat: { isDirectory: () => false, size: bytes.length, mtime: now },
});
}
}
return entries;
}
@@ -148,7 +195,9 @@ export class SmbSession {
case CMD_LOGOFF_ANDX: return this.logoff(req);
case CMD_NT_CREATE_ANDX: return this.ntCreate(req);
case CMD_OPEN_ANDX: return this.openAndx(req);
case CMD_READ: return this.coreRead(req);
case CMD_READ_ANDX: return this.read(req);
case CMD_SEEK: return this.seek(req);
case CMD_CLOSE: return this.close(req);
case CMD_TRANSACTION: return this.transRap(req);
case CMD_TRANSACTION2: return this.trans2(req);
@@ -186,19 +235,44 @@ export class SmbSession {
}
log("dialects offered:", offered);
let pick = -1;
let pick = -1, picked = "";
for (const d of DIALECTS) {
const idx = offered.indexOf(d);
if (idx >= 0) { pick = idx; break; }
if (idx >= 0) { pick = idx; picked = d; break; }
}
if (pick < 0) {
// refuse — but Win95 always offers at least LANMAN
const w = new Writer().u16(0xffff).build();
return buildSmb(req, CMD_NEGOTIATE, 0, w, new Uint8Array(0));
}
log(`negotiate → "${picked}" (idx ${pick})`);
// LM 2.1 / NT-compatible response (13 words). We claim share-level
// security (no challenge), no encryption, modest buffer.
if (picked === NT_DIALECT) {
// NT LM 0.12 response: 17 words. Capabilities = 0 keeps Win95 on the
// codepaths we already implement: OEM strings (no CAP_UNICODE), DOS
// errors (no CAP_NT_STATUS), SMB_INFO_STANDARD find (no CAP_NT_FIND),
// OPEN_ANDX over NT_CREATE (no CAP_NT_SMBS). The dialect alone is what
// flips the redirector from CMD_SEARCH to TRANS2/FIND_FIRST2.
const words = new Writer()
.u16(pick) // DialectIndex
.u8(0x00) // SecurityMode: share-level, no challenge
.u16(1) // MaxMpxCount
.u16(1) // MaxNumberVcs
.u32(16384) // MaxBufferSize
.u32(0) // MaxRawSize (no raw)
.u32(0) // SessionKey
.u32(0) // Capabilities
.u64(0) // SystemTime (FILETIME — Win95 ignores 0)
.u16(0) // ServerTimeZone
.u8(0) // ChallengeLength = 0
.build();
const bytes = new Writer().cstr("WORKGROUP").build();
// FLAGS2_LONG_NAMES on the negotiate reply itself signals "I can return
// long names" — Win95 keys its FIND_FIRST2 info-level on this bit.
return buildSmb(req, CMD_NEGOTIATE, 0, words, bytes, { flags2: 0x0001 });
}
// LM 2.1 response (13 words). Share-level security, no challenge.
const words = new Writer()
.u16(pick) // DialectIndex
.u16(0x0000) // SecurityMode: share-level, no challenge
@@ -252,11 +326,12 @@ export class SmbSession {
const service = br.cstr();
log(`tree connect: path="${reqPath}" service="${service}"`);
// Accept anything for now — share name extraction is a refinement.
// IPC$ is special (named pipes); we pretend to support it so the
// redirector doesn't bail, but file ops on tid 0xfffe will error out.
const isIpc = /\\IPC\$$/i.test(reqPath);
this.tid = isIpc ? 0xfffe : 1;
// Path is \\SERVER\SHARE — route by the share segment. Unknown names fall
// through to the user share so a stale `net use` (e.g. from before the
// user re-pointed the mounted folder) still connects to *something*.
const share = reqPath.split(/[\\\/]/).pop()?.toUpperCase() ?? "";
const isIpc = share === "IPC$";
this.tid = isIpc ? TID_IPC : share === TOOLS_SHARE ? TID_TOOLS : TID_SHARE;
const words = new Writer()
.bytes(andxNone())
@@ -290,7 +365,8 @@ export class SmbSession {
// each component is mapped through the SFN table. Refuses traversal,
// including via symlinks.
// ───────────────────────────────────────────────────────────────────────────
private resolve(smbPath: string): string | null {
private resolve(tid: number, smbPath: string): string | null {
if (tid !== TID_SHARE) return null; // TOOLS/IPC have no backing fs
let p = smbPath.replace(/\\/g, "/");
if (p.startsWith("/")) p = p.slice(1);
@@ -349,18 +425,22 @@ export class SmbSession {
private queryInfo(req: SmbHeader): Uint8Array {
const smbPath = this.smbPathFromBytes(req);
const v = this.getVirtual(smbPath);
if (v) {
if (req.tid === TID_TOOLS) {
const v = this.getVirtual(req.tid, smbPath);
if (!v && !isRootPath(smbPath)) {
return buildSmb(req, CMD_QUERY_INFORMATION, dosError(ERRDOS, ERR_BADFILE),
new Uint8Array(0), new Uint8Array(0));
}
const words = new Writer()
.u16(ATTR_ARCHIVE)
.u16(v ? ATTR_ARCHIVE : ATTR_DIRECTORY)
.u32(unixToSmbTime(new Date()))
.u32(v.length)
.u32(v?.length ?? 0)
.zero(10)
.build();
return buildSmb(req, CMD_QUERY_INFORMATION, 0, words, new Uint8Array(0));
}
const hostPath = this.resolve(smbPath);
const hostPath = this.resolve(req.tid, smbPath);
if (!hostPath || !fs.existsSync(hostPath)) {
return buildSmb(req, CMD_QUERY_INFORMATION, dosError(ERRDOS, ERR_BADFILE),
new Uint8Array(0), new Uint8Array(0));
@@ -378,12 +458,12 @@ export class SmbSession {
private checkDirectory(req: SmbHeader): Uint8Array {
const smbPath = this.smbPathFromBytes(req);
// Virtuals are files — explicitly NOT directories
if (this.getVirtual(smbPath)) {
return buildSmb(req, CMD_CHECK_DIRECTORY, dosError(ERRDOS, ERR_BADPATH),
if (req.tid === TID_TOOLS) {
const ok = isRootPath(smbPath);
return buildSmb(req, CMD_CHECK_DIRECTORY, ok ? 0 : dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
}
const hostPath = this.resolve(smbPath);
const hostPath = this.resolve(req.tid, smbPath);
if (!hostPath || !fs.existsSync(hostPath) || !fs.statSync(hostPath).isDirectory()) {
return buildSmb(req, CMD_CHECK_DIRECTORY, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
@@ -423,16 +503,24 @@ export class SmbSession {
}
private doOpen(req: SmbHeader, cmd: number, smbPath: string): Uint8Array {
// Virtual root files first — they shadow anything on disk with the same name
const vbytes = this.getVirtual(smbPath);
const vbytes = this.getVirtual(req.tid, smbPath);
if (vbytes) {
const fid = this.nextFid++;
this.fids.set(fid, { hostPath: `<virtual>${smbPath}`, fd: -1, size: vbytes.length, isDir: false, virtual: vbytes });
log(`open "${smbPath}" → virtual (${vbytes.length} bytes)`);
return this.buildOpenReply(req, cmd, fid, false, vbytes.length, new Date());
}
if (req.tid === TID_TOOLS) {
if (isRootPath(smbPath)) {
const fid = this.nextFid++;
this.fids.set(fid, { hostPath: "<tools>", fd: -1, size: 0, isDir: true });
return this.buildOpenReply(req, cmd, fid, true, 0, new Date());
}
return buildSmb(req, cmd, dosError(ERRDOS, ERR_BADFILE),
new Uint8Array(0), new Uint8Array(0));
}
const hostPath = this.resolve(smbPath);
const hostPath = this.resolve(req.tid, smbPath);
log(`open "${smbPath}" → ${hostPath}`);
if (!hostPath || !fs.existsSync(hostPath)) {
return buildSmb(req, cmd, dosError(ERRDOS, ERR_BADFILE),
@@ -532,6 +620,55 @@ export class SmbSession {
return buildSmb(req, CMD_READ_ANDX, 0, words, bytes);
}
// READ (0x0a): the original core-protocol read. With Capabilities=0 in our
// NT negotiate, Win95's redirector uses this instead of READ_ANDX.
// Request words: FID(2) Count(2) Offset(4) Remaining(2).
// Response words: Count(2) Reserved(8); bytes: 0x01 DataLen(2) Data.
private coreRead(req: SmbHeader): Uint8Array {
const wr = new Reader(req.words);
const fid = wr.u16();
const count = wr.u16();
const offset = wr.u32();
const data = this.readBytes(fid, offset, count);
if (!data) {
return buildSmb(req, CMD_READ, dosError(ERRDOS, ERR_BADFID),
new Uint8Array(0), new Uint8Array(0));
}
const words = new Writer().u16(data.length).zero(8).build();
const bytes = new Writer().u8(0x01).u16(data.length).bytes(data).build();
return buildSmb(req, CMD_READ, 0, words, bytes);
}
private readBytes(fid: number, offset: number, count: number): 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));
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);
}
// 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
// re-opens for the actual read. ERR_BADFUNC here makes Explorer wedge.
private seek(req: SmbHeader): Uint8Array {
const wr = new Reader(req.words);
const fid = wr.u16();
const mode = wr.u16();
const off = wr.u32() | 0; // signed
const file = this.fids.get(fid);
if (!file) {
return buildSmb(req, CMD_SEEK, dosError(ERRDOS, ERR_BADFID),
new Uint8Array(0), new Uint8Array(0));
}
const base = mode === 2 ? file.size : mode === 1 ? (file.pos ?? 0) : 0;
file.pos = Math.max(0, base + off);
const words = new Writer().u32(Math.min(file.pos, 0xffffffff)).build();
return buildSmb(req, CMD_SEEK, 0, words, new Uint8Array(0));
}
private close(req: SmbHeader): Uint8Array {
const wr = new Reader(req.words);
const fid = wr.u16();
@@ -577,20 +714,31 @@ export class SmbSession {
const lastSep = Math.max(pattern.lastIndexOf("\\"), pattern.lastIndexOf("/"));
const dirPart = lastSep >= 0 ? pattern.slice(0, lastSep) : "";
const namePart = lastSep >= 0 ? pattern.slice(lastSep + 1) : pattern;
const hostDir = this.resolve(dirPart || "\\");
log(`SEARCH "${pattern}" → ${hostDir}`);
if (!hostDir || !fs.existsSync(hostDir)) {
return buildSmb(req, CMD_SEARCH, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
log(`SEARCH "${pattern}" tid=${req.tid}`);
let all: DirEntry[];
let dotStat: DirEntry["stat"];
if (req.tid === TID_TOOLS) {
if (!isRootPath(dirPart)) {
return buildSmb(req, CMD_SEARCH, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
}
all = this.listTools();
dotStat = { isDirectory: () => true, size: 0, mtime: new Date() };
} else {
const hostDir = this.resolve(req.tid, dirPart || "\\");
if (!hostDir || !fs.existsSync(hostDir)) {
return buildSmb(req, CMD_SEARCH, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
}
all = this.listDir(hostDir);
dotStat = fs.statSync(hostDir);
}
const matcher = wildcardMatcher(namePart);
const all = this.listDir(hostDir);
// Match against the SFN — that's what the client sees and asks for
const entries = all.filter(e => matcher(e.sfn));
// . and .. only for wildcard listings — a single-name SEARCH is a stat
// probe and must return exactly the matching file or nothing.
if (/[*?]/.test(namePart)) {
const dotStat = fs.statSync(hostDir);
entries.unshift(
{ name: "..", sfn: "..", stat: dotStat },
{ name: ".", sfn: ".", stat: dotStat },
@@ -685,6 +833,7 @@ export class SmbSession {
switch (subCmd) {
case TRANS2_FIND_FIRST2: return this.findFirst(req, params);
case TRANS2_FIND_NEXT2: return this.findNext(req, params);
case TRANS2_QUERY_FS_INFO: return this.queryFsInfo(req, params);
case TRANS2_QUERY_PATH_INFO: return this.queryPathInfo(req, params);
default:
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRSRV, ERR_BADFUNC),
@@ -697,28 +846,39 @@ export class SmbSession {
// SearchStorageType(4) FileName(string)
const pr = new Reader(params);
pr.u16(); // searchAttrs
pr.u16(); // searchCount
pr.u16(); // flags
const searchCount = pr.u16();
const findFlags = pr.u16();
const infoLevel = pr.u16();
pr.u32(); // storageType
const pattern = (req.flags2 & 0x8000) ? pr.ucs2() : pr.cstr();
log(`FIND_FIRST2 level=0x${infoLevel.toString(16)} pattern="${pattern}"`);
log(`FIND_FIRST2 level=0x${infoLevel.toString(16)} flags=0x${findFlags.toString(16)} pattern="${pattern}"`);
// pattern is like "\dir\*" or "\*" or "\file.txt"
const lastSep = Math.max(pattern.lastIndexOf("\\"), pattern.lastIndexOf("/"));
const dirPart = lastSep >= 0 ? pattern.slice(0, lastSep) : "";
const namePart = lastSep >= 0 ? pattern.slice(lastSep + 1) : pattern;
const hostDir = this.resolve(dirPart || "\\");
if (!hostDir || !fs.existsSync(hostDir)) {
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
let all: DirEntry[];
let dotStat: DirEntry["stat"];
if (req.tid === TID_TOOLS) {
if (!isRootPath(dirPart)) {
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
}
all = this.listTools();
dotStat = { isDirectory: () => true, size: 0, mtime: new Date() };
} else {
const hostDir = this.resolve(req.tid, dirPart || "\\");
if (!hostDir || !fs.existsSync(hostDir)) {
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, ERR_BADPATH),
new Uint8Array(0), new Uint8Array(0));
}
all = this.listDir(hostDir);
dotStat = fs.statSync(hostDir);
}
const matcher = wildcardMatcher(namePart);
const all = this.listDir(hostDir);
const entries = all.filter(e => matcher(e.sfn) || matcher(e.name));
if (/[*?]/.test(namePart)) {
const dotStat = fs.statSync(hostDir);
entries.unshift(
{ name: "..", sfn: "..", stat: dotStat },
{ name: ".", sfn: ".", stat: dotStat },
@@ -727,19 +887,21 @@ export class SmbSession {
const sid = this.nextSid++;
this.sids.set(sid, { entries, idx: 0 });
return this.findReply(req, sid, infoLevel, true);
return this.findReply(req, sid, infoLevel, findFlags, searchCount, true);
}
private findNext(req: SmbHeader, params: Uint8Array): Uint8Array {
const pr = new Reader(params);
const sid = pr.u16();
pr.u16(); // searchCount
const searchCount = pr.u16();
const infoLevel = pr.u16();
// ResumeKey(4) Flags(2) FileName — we just continue from where we left off
return this.findReply(req, sid, infoLevel, false);
pr.u32(); // resumeKey
const findFlags = pr.u16();
return this.findReply(req, sid, infoLevel, findFlags, searchCount, false);
}
private findReply(req: SmbHeader, sid: number, _infoLevel: number, isFirst: boolean): Uint8Array {
private findReply(req: SmbHeader, sid: number, infoLevel: number,
findFlags: number, maxCount: number, isFirst: boolean): Uint8Array {
const search = this.sids.get(sid);
if (!search || search.idx >= search.entries.length) {
this.sids.delete(sid);
@@ -747,33 +909,89 @@ export class SmbSession {
new Uint8Array(0), new Uint8Array(0));
}
// We return SMB_INFO_STANDARD (level 1) regardless of what was asked —
// Win95 accepts this. Each entry: ResumeKey(4) CreationDate(2) CreationTime(2)
// LastAccessDate(2) LastAccessTime(2) LastWriteDate(2) LastWriteTime(2)
// DataSize(4) AllocationSize(4) Attributes(2) FileNameLength(1) FileName
// Max ~500 bytes per entry batch to keep under our buffer cap.
const data = new Writer();
let count = 0;
let lastNameOffset = 0;
while (search.idx < search.entries.length && data.length < 8000) {
const e = search.entries[search.idx++];
const dosDate = unixToDosDateTime(e.stat.mtime);
const sz = Math.min(e.stat.size, 0xffffffff);
const entryStart = data.length;
data.u32(search.idx); // ResumeKey
data.u16(dosDate.date).u16(dosDate.time); // create
data.u16(dosDate.date).u16(dosDate.time); // access
data.u16(dosDate.date).u16(dosDate.time); // write
data.u32(sz);
data.u32(sz);
data.u16(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE);
data.u8(e.name.length);
lastNameOffset = data.length - entryStart;
data.cstr(e.name);
count++;
// Win95 sends SearchCount=6 and MaxDataCount≈2.4KB; blow past either and
// VREDIR drops the whole TCP session ("network resource no longer
// available"). Cap on count, then a byte ceiling well under MaxDataCount
// as a backstop for pathological filenames.
const fits = () =>
search.idx < search.entries.length && count < maxCount && data.length < 2000;
if (infoLevel === 0x104) {
// SMB_FIND_FILE_BOTH_DIRECTORY_INFO — what Win95 asks for under
// NT LM 0.12 regardless of CAP_NT_FIND. 94 bytes fixed + long name,
// 4-byte aligned. NextEntryOffset chains entries; 0 terminates.
// ShortName is *always* UTF-16LE per spec even though FileName stays
// OEM (we never set CAP_UNICODE). 0x10x levels never take a resume-key
// prefix.
let prevStart = -1;
while (fits()) {
const e = search.entries[search.idx++];
const ft = unixToFiletime(e.stat.mtime);
const sz = e.stat.isDirectory() ? 0 : Math.min(e.stat.size, 0xffffffff);
const entryStart = data.length;
if (prevStart >= 0) data.patch32(prevStart, entryStart - prevStart);
prevStart = entryStart;
data.u32(0); // NextEntryOffset — patched on next iter
data.u32(search.idx); // FileIndex
data.u64(ft.lo, ft.hi); // CreationTime
data.u64(ft.lo, ft.hi); // LastAccessTime
data.u64(ft.lo, ft.hi); // LastWriteTime
data.u64(ft.lo, ft.hi); // ChangeTime
data.u64(sz); // EndOfFile
data.u64(sz); // AllocationSize
data.u32(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE);
// Samba (win9x-tested) writes FileName null-terminated AND counts the
// null in FileNameLength; vredir copies the resume name as a C string
// from LastNameOffset, so an unterminated name reads past the buffer.
data.u32(e.name.length + 1);
data.u32(0); // EaSize
// ShortNameLength=0 always. MS-CIFS says ShortName is UCS-2 even in
// an OEM session, but Win95's redirector reads it as OEM when
// FLAGS2_UNICODE is clear — a non-empty UCS-2 name here makes
// shell32 GPF on the single-directory probe it does when navigating
// into a subfolder (root listing survives because Explorer never
// looks at this field there). Explorer doesn't need the short name
// anyway; it has the long one.
data.u8(0);
data.u8(0); // Reserved
data.zero(24); // ShortName WCHAR[12]
lastNameOffset = data.length;
for (let k = 0; k < e.name.length; k++) data.u8(e.name.charCodeAt(k));
data.u8(0);
data.zero((4 - (data.length & 3)) & 3);
count++;
}
} else {
// SMB_INFO_STANDARD (level 1). Each entry: [ResumeKey(4)] CDate(2)
// CTime(2) ADate(2) ATime(2) WDate(2) WTime(2) Size(4) Alloc(4)
// Attrs(2) NameLen(1) Name\0. ResumeKey prefix is ONLY present if the
// client set SMB_FIND_RETURN_RESUME_KEYS — emit it unconditionally
// and the client misparses every entry by 4 bytes.
const wantResumeKey = (findFlags & 0x0004) !== 0;
while (fits()) {
const e = search.entries[search.idx++];
const dosDate = unixToDosDateTime(e.stat.mtime);
const sz = Math.min(e.stat.size, 0xffffffff);
const entryStart = data.length;
if (wantResumeKey) data.u32(search.idx);
data.u16(dosDate.date).u16(dosDate.time);
data.u16(dosDate.date).u16(dosDate.time);
data.u16(dosDate.date).u16(dosDate.time);
data.u32(sz);
data.u32(sz);
data.u16(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE);
data.u8(e.name.length);
lastNameOffset = data.length - entryStart;
data.cstr(e.name);
count++;
}
}
const eos = search.idx >= search.entries.length;
if (eos) this.sids.delete(sid);
log(`${count} entries, ${data.length} bytes, eos=${eos}`);
// params reply differs: FIND_FIRST has SID(2), FIND_NEXT doesn't
const pw = new Writer();
@@ -785,14 +1003,74 @@ export class SmbSession {
return this.trans2Reply(req, pw.build(), data.build());
}
private queryFsInfo(req: SmbHeader, params: Uint8Array): Uint8Array {
const level = params[0] | (params[1] << 8);
log(`QUERY_FS_INFO level=0x${level.toString(16)}`);
let data: Uint8Array;
switch (level) {
case 0x0105: { // SMB_QUERY_FS_ATTRIBUTE_INFO
// "FAT", not "NTFS" — shell32 keys NTFS-specific property/security
// handlers on this string, and they fault on subfolder entry when
// the backing protocol can't answer their follow-ups.
const fsName = "FAT";
data = new Writer()
.u32(0x00000002) // FILE_CASE_PRESERVED_NAMES — the bit Win95 reads to decide LFN-capable
.u32(255) // MaxFileNameLengthInBytes
.u32(fsName.length)
.bytes([...fsName].map(c => c.charCodeAt(0)))
.build();
break;
}
case 0x0001: { // SMB_INFO_ALLOCATION
data = new Writer()
.u32(0) // idFileSystem
.u32(8) // SectorsPerAllocationUnit
.u32(0x10000) // TotalAllocationUnits — fake but plausible
.u32(0x08000) // TotalFreeAllocationUnits
.u16(512) // BytesPerSector
.build();
break;
}
case 0x0002: { // SMB_INFO_VOLUME
const label = req.tid === TID_TOOLS ? TOOLS_SHARE : this.shareName;
data = new Writer()
.u32(0) // VolumeSerialNumber
.u8(label.length)
.bytes([...label].map(c => c.charCodeAt(0)))
.build();
break;
}
default:
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, 124 /* ERROR_INVALID_LEVEL */),
new Uint8Array(0), new Uint8Array(0));
}
return this.trans2Reply(req, new Uint8Array(0), data);
}
private queryPathInfo(req: SmbHeader, params: Uint8Array): Uint8Array {
// params: InfoLevel(2) Reserved(4) FileName
const pr = new Reader(params);
const level = pr.u16();
pr.u32();
const smbPath = (req.flags2 & 0x8000) ? pr.ucs2() : pr.cstr();
const hostPath = this.resolve(smbPath);
log(`QUERY_PATH_INFO level=0x${level.toString(16)} "${smbPath}"`);
if (req.tid === TID_TOOLS) {
const v = this.getVirtual(req.tid, smbPath);
const isDir = !v && isRootPath(smbPath);
if (!v && !isDir) {
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, ERR_BADFILE),
new Uint8Array(0), new Uint8Array(0));
}
const dd = unixToDosDateTime(new Date());
const sz = v?.length ?? 0;
const data = new Writer()
.u16(dd.date).u16(dd.time).u16(dd.date).u16(dd.time).u16(dd.date).u16(dd.time)
.u32(sz).u32(sz)
.u16(isDir ? ATTR_DIRECTORY : ATTR_ARCHIVE)
.build();
return this.trans2Reply(req, new Writer().u16(0).build(), data);
}
const hostPath = this.resolve(req.tid, smbPath);
if (!hostPath || !fs.existsSync(hostPath)) {
return buildSmb(req, CMD_TRANSACTION2, dosError(ERRDOS, ERR_BADFILE),
new Uint8Array(0), new Uint8Array(0));
@@ -868,7 +1146,8 @@ export class SmbSession {
// W = 2-byte type (0=disk, 3=IPC)
// z = 4-byte string pointer (we send 0 = no remark)
const shares = [
{ name: "HOST", type: 0 },
{ name: this.shareName, type: 0 },
{ name: TOOLS_SHARE, type: 0 },
{ name: "IPC$", type: 3 },
];
const data = new Writer();
@@ -924,14 +1203,19 @@ export class SmbSession {
/** Build the TRANS2 response envelope. Tedious but mechanical. */
private trans2Reply(req: SmbHeader, params: Uint8Array, data: Uint8Array): Uint8Array {
// 10 words + 1 setup word, then bytes = pad + params + pad + data
// Offsets are from SMB header start (32 bytes before word_count byte).
const wc = 10 + 1; // SetupCount=1 → 1 setup word
const wordBlockSize = 1 + wc * 2 + 2; // wc byte + words + bcc
// bytes block: pad to align params (we don't bother), params, pad, data
const paramOffset = 32 + wordBlockSize;
const dataOffset = paramOffset + params.length;
// 10 words + 0 setup, then bytes = pad + params + pad + data. Both pads
// bring the following block to a 4-byte boundary from the SMB header
// start — Win95's redirector copies params/data via REP MOVSD and an
// odd offset shifts the entire 0x104 record by up to 3 bytes inside its
// buffer, which explorer survives at the root but GPFs on the second
// single-entry probe when navigating into a subfolder.
const wc = 10;
const bytesStart = 32 + 1 + wc * 2 + 2; // header + wc + words + bcc
const align4 = (n: number) => (4 - (n & 3)) & 3;
const pad1 = align4(bytesStart);
const paramOffset = bytesStart + pad1;
const pad2 = align4(paramOffset + params.length);
const dataOffset = paramOffset + params.length + pad2;
const words = new Writer()
.u16(params.length) // TotalParamCount
@@ -943,14 +1227,13 @@ export class SmbSession {
.u16(data.length) // DataCount
.u16(dataOffset) // DataOffset
.u16(0) // DataDisplacement
.u8(1) // SetupCount
.u8(0) // SetupCount
.u8(0) // Reserved
.u16(0) // Setup[0]
.build();
const bytes = new Uint8Array(params.length + data.length);
bytes.set(params, 0);
bytes.set(data, params.length);
const bytes = new Uint8Array(pad1 + params.length + pad2 + data.length);
bytes.set(params, pad1);
bytes.set(data, pad1 + params.length + pad2);
return buildSmb(req, CMD_TRANSACTION2, 0, words, bytes);
}
@@ -966,6 +1249,23 @@ export class SmbSession {
// ─── helpers ─────────────────────────────────────────────────────────────────
/** Map a host filename to something Win95 can display: single-byte, no
* Windows-reserved chars, no controls. Multi-code-unit chars collapse to one
* '_' so the visible length matches what a user would expect. */
function oemSafe(name: string): string {
const bad = /[<>:"/\\|?*\x00-\x1f]/;
let out = "";
for (const ch of name) { // iterates code points, so 🎨 is one step
const c = ch.codePointAt(0)!;
out += c > 0xff || bad.test(ch) ? "_" : ch;
}
return out;
}
function isRootPath(smbPath: string): boolean {
return smbPath.replace(/[\\\/]/g, "") === "";
}
function wildcardMatcher(pattern: string): (name: string) => boolean {
// SMB wildcards: * = any, ? = one char, also ">"/"<"/"\"" exist but
// Win95 mostly sends *.* or * — collapse *.* → *
@@ -1052,6 +1352,12 @@ function unixToSmbTime(d: Date): number {
return Math.floor(d.getTime() / 1000);
}
/** NT FILETIME: 100ns ticks since 1601-01-01, split into two u32s. */
function unixToFiletime(d: Date): { lo: number; hi: number } {
const t = BigInt(d.getTime()) * 10000n + 116444736000000000n;
return { lo: Number(t & 0xffffffffn), hi: Number(t >> 32n) };
}
function clean83(s: string): string {
return s.replace(/[^A-Za-z0-9_$~!#%&'()@^`{}-]/g, "").toUpperCase();
}

View File

@@ -16,7 +16,9 @@ export const CMD_TREE_DISCONNECT = 0x71;
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_ANDX = 0x2e;
export const CMD_SEEK = 0x12;
export const CMD_CLOSE = 0x04;
export const CMD_TRANSACTION = 0x25;
export const CMD_TRANSACTION2 = 0x32;
@@ -30,6 +32,7 @@ export const CMD_SEARCH = 0x81;
// TRANS2 subcommands
export const TRANS2_FIND_FIRST2 = 0x01;
export const TRANS2_FIND_NEXT2 = 0x02;
export const TRANS2_QUERY_FS_INFO = 0x03;
export const TRANS2_QUERY_PATH_INFO = 0x05;
export const TRANS2_QUERY_FILE_INFO = 0x07;
@@ -142,6 +145,8 @@ export const cmdName: Record<number, string> = {
[CMD_NT_CREATE_ANDX]: "NT_CREATE",
[CMD_OPEN_ANDX]: "OPEN",
[CMD_READ_ANDX]: "READ",
[CMD_READ]: "READ",
[CMD_SEEK]: "SEEK",
[CMD_CLOSE]: "CLOSE",
[CMD_TRANSACTION]: "TRANS(RAP)",
[CMD_TRANSACTION2]: "TRANS2",

View File

@@ -93,12 +93,18 @@ console.log("\n[2] NEGOTIATE");
ok(parsed.cmd === CMD_NEGOTIATE, "cmd echoed");
ok((parsed.flags & 0x80) !== 0, "reply flag set");
ok(parsed.status === 0, "status OK");
ok(parsed.wordCount === 13, "13-word LM response");
// word[0] = dialect index — we pick LANMAN2.1 (idx 3) since our 13-word
// response is the LANMAN format; picking NT LM 0.12 would require the
// 17-word NT response which we don't implement
ok(parsed.wordCount === 17, "17-word NT response");
// word[0] = dialect index — NT LM 0.12 is idx 4 and gets the 17-word
// response; the 13-word LM shape is now only emitted as a fallback.
const pickedIdx = parsed.words[0] | (parsed.words[1] << 8);
ok(pickedIdx === 3, `picked LANMAN2.1 (idx ${pickedIdx})`);
ok(pickedIdx === 4, `picked NT LM 0.12 (idx ${pickedIdx})`);
// Fallback: a client that doesn't offer NT LM 0.12 still gets the 13-word
// LANMAN response.
const lmBytes: number[] = [];
for (const d of dialects.slice(0, 4)) { lmBytes.push(0x02); lmBytes.push(...cstr(d)); }
const lmParsed = parseSmb(session.handle(smbReq(CMD_NEGOTIATE, [], lmBytes))!)!;
ok(lmParsed.wordCount === 13, "13-word LM fallback");
}
// ─── Test 3: SESSION_SETUP ───────────────────────────────────────────────────
@@ -170,15 +176,85 @@ console.log("\n[5] TRANS2 FIND_FIRST2");
const pStart = replyParamOffset - replyBytesStart;
const replyParams = parsed.bytes.slice(pStart, pStart + replyParamCount);
const searchCount = replyParams[2] | (replyParams[3] << 8);
// Should find: . .. _MAPZ.BAT(virtual) hello.txt subdir = 5
ok(searchCount === 5, `found ${searchCount} entries (expect 5)`);
// Should find: . .. hello.txt subdir = 4 (virtuals moved to TOOLS share)
ok(searchCount === 4, `found ${searchCount} entries (expect 4)`);
// Data block has the entries — just verify they're in there somewhere
const dataStr = String.fromCharCode(...parsed.bytes);
ok(dataStr.includes("_MAPZ.BAT"), "virtual _MAPZ.BAT in listing");
ok(!dataStr.includes("_MAPZ.BAT"), "no virtual leak in user share");
ok(dataStr.includes("hello.txt"), "hello.txt in listing");
ok(dataStr.includes("subdir"), "subdir in listing");
}
// ─── Test 5b: FIND_FIRST2 level 0x104 (LFN) ──────────────────────────────────
console.log("\n[5b] TRANS2 FIND_FIRST2 level=0x104");
{
fs.writeFileSync(path.join(tmpRoot, "A Long Filename Here.txt"), "lfn");
// Same envelope as [5] but InfoLevel=0x104
const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(0x104),
...u32(0), ...cstr("\\*")];
const wc = 14 + 1;
const bytesStart = 32 + 1 + wc * 2 + 2;
const paramOff = bytesStart + 3;
const words = [
...u16(t2params.length), ...u16(0), ...u16(100), ...u16(8000),
1, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(t2params.length), ...u16(paramOff),
...u16(0), ...u16(0),
1, 0, ...u16(1)
];
const bytes = [0, 0, 0, ...t2params];
const reply = session.handle(smbReq(CMD_TRANSACTION2, words, bytes, 1, 1))!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
// Walk the data block via NextEntryOffset and verify the long name appears
// intact and the chain terminates with 0.
const rw = parsed.words;
const dOff = (rw[14] | (rw[15] << 8)) - (32 + 1 + parsed.wordCount * 2 + 2);
const dLen = rw[12] | (rw[13] << 8);
const data = parsed.bytes.slice(dOff, dOff + dLen);
const names: string[] = [];
let off = 0;
for (;;) {
const next = data[off] | (data[off+1]<<8) | (data[off+2]<<16) | (data[off+3]<<24);
const fnLen = data[off+60] | (data[off+61]<<8);
// FileNameLength counts the trailing null (Samba/win9x compat)
names.push(String.fromCharCode(...data.slice(off+94, off+94+fnLen)).replace(/\0$/, ""));
if (next === 0) break;
off += next;
}
ok(names.includes("A Long Filename Here.txt"), `LFN intact: ${JSON.stringify(names)}`);
ok(names.includes(".") && names.includes(".."), "dot entries present");
}
// ─── Test 5c: RAP NetShareEnum lists user share + TOOLS ──────────────────────
console.log("\n[5c] RAP NetShareEnum");
{
// TREE_CONNECT IPC$ first
const ipc = parseSmb(session.handle(smbReq(CMD_TREE_CONNECT_ANDX,
[0xff,0,0,0,...u16(0),...u16(1)],
[0, ...cstr("\\\\HOST\\IPC$"), ...cstr("IPC")], 0, 1))!)!;
ok(ipc.tid === 0xfffe, `IPC$ tid=${ipc.tid}`);
// RAP NetShareEnum: TRANS over \PIPE\LANMAN
const rap = [...u16(0), ...cstr("WrLeh"), ...cstr("B13BWz"), ...u16(1), ...u16(4096)];
const wc = 14;
const bytesStart = 32 + 1 + wc * 2 + 2;
const name = cstr("\\PIPE\\LANMAN");
const paramOff = bytesStart + name.length;
const words = [
...u16(rap.length), ...u16(0), ...u16(100), ...u16(4096),
0, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(rap.length), ...u16(paramOff),
...u16(0), ...u16(0),
0, 0
];
const reply = session.handle(smbReq(0x25, words, [...name, ...rap], ipc.tid, 1))!;
const dataStr = String.fromCharCode(...parseSmb(reply)!.bytes);
const userShare = path.basename(tmpRoot).replace(/[^A-Za-z0-9_$~!#%&'()@^`{}.-]/g, "")
.toUpperCase().slice(0, 12);
ok(dataStr.includes("TOOLS"), "TOOLS share listed");
ok(dataStr.includes(userShare), `user share "${userShare}" listed`);
}
// ─── Test 6: OPEN + READ + CLOSE ─────────────────────────────────────────────
console.log("\n[6] OPEN_ANDX + READ_ANDX + CLOSE");
let openedFid = 0;
@@ -243,21 +319,50 @@ console.log("\n[7] Error handling");
ok(parsed.status !== 0, "lexical traversal (../) blocked");
}
{
// Virtual file: open and read _MAPZ.BAT
// Virtual file: connect to TOOLS share, open and read _MAPZ.BAT
const tcReq = smbReq(CMD_TREE_CONNECT_ANDX,
[0xff, 0, 0, 0, ...u16(0), ...u16(1)],
[0, ...cstr("\\\\192.168.86.1\\TOOLS"), ...cstr("?????")], 0, 1);
const tcParsed = parseSmb(session.handle(tcReq)!)!;
ok(tcParsed.tid === 2, `TOOLS share tid=${tcParsed.tid}`);
const oReq = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\_MAPZ.BAT")], 1, 1);
[...cstr("\\_MAPZ.BAT")], tcParsed.tid, 1);
const oReply = session.handle(oReq)!;
const oParsed = parseSmb(oReply)!;
ok(oParsed.status === 0, "open virtual _MAPZ.BAT");
const vfid = oParsed.words[4] | (oParsed.words[5] << 8);
const rReq = smbReq(CMD_READ_ANDX,
[0xff,0,0,0,...u16(vfid),...u32(0),...u16(500),...u16(0),...u32(0),...u16(0)], [], 1, 1);
[0xff,0,0,0,...u16(vfid),...u32(0),...u16(500),...u16(0),...u32(0),...u16(0)], [], tcParsed.tid, 1);
const rReply = session.handle(rReq)!;
const rParsed = parseSmb(rReply)!;
const len = rParsed.words[10] | (rParsed.words[11] << 8);
const text = String.fromCharCode(...rParsed.bytes.slice(1, 1 + len));
ok(text.includes("NET USE Z:"), `virtual read: ${JSON.stringify(text.slice(0, 40))}`);
// SEEK to end → file size, then core READ (0x0a). This is the exact path
// Win95+Notepad take under NT LM 0.12 with Capabilities=0.
const oReq2 = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\README.TXT")], tcParsed.tid, 1);
const oP2 = parseSmb(session.handle(oReq2)!)!;
const fid2 = oP2.words[4] | (oP2.words[5] << 8);
ok(oP2.status === 0 && fid2 > 0, `open README.TXT fid=${fid2}`);
const sP = parseSmb(session.handle(smbReq(0x12,
[...u16(fid2), ...u16(2), ...u32(0)], [], tcParsed.tid, 1))!)!;
const seekPos = sP.words[0] | (sP.words[1] << 8) | (sP.words[2] << 16) | (sP.words[3] << 24);
ok(sP.status === 0 && seekPos > 100, `SEEK end → size=${seekPos}`);
const rP = parseSmb(session.handle(smbReq(0x0a,
[...u16(fid2), ...u16(seekPos), ...u32(0), ...u16(seekPos)], [], tcParsed.tid, 1))!)!;
ok(rP.status === 0, "core READ status OK");
// bytes: 0x01 + len(2) + data
const dlen = rP.bytes[1] | (rP.bytes[2] << 8);
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))}`);
}
{
// symlink escape: link inside share → file outside share

View File

@@ -45,6 +45,13 @@ export class Writer {
this.chunks.push(0, 0);
return this;
}
patch32(at: number, v: number) {
this.chunks[at] = v & 0xff;
this.chunks[at+1] = (v >>> 8) & 0xff;
this.chunks[at+2] = (v >>> 16) & 0xff;
this.chunks[at+3] = (v >>> 24) & 0xff;
return this;
}
get length() { return this.chunks.length; }
build() { return new Uint8Array(this.chunks); }
}