From 148f8e4874f3dfcfe66a9fbb7a7386b4736589a2 Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Sat, 11 Apr 2026 12:26:54 -0700 Subject: [PATCH] SMB: long filenames, basename-derived share name, synthetic TOOLS share (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/renderer/card-start.tsx | 2 +- src/renderer/debug-harness.ts | 4 +- src/renderer/smb/README.md | 30 +- src/renderer/smb/index.ts | 7 +- src/renderer/smb/server.ts | 522 ++++++++++++++++++++++------ src/renderer/smb/smb.ts | 5 + src/renderer/smb/test-standalone.ts | 127 ++++++- src/renderer/smb/wire.ts | 7 + 8 files changed, 570 insertions(+), 134 deletions(-) diff --git a/src/renderer/card-start.tsx b/src/renderer/card-start.tsx index 061c49d..9c5e22a 100644 --- a/src/renderer/card-start.tsx +++ b/src/renderer/card-start.tsx @@ -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.", ]; diff --git a/src/renderer/debug-harness.ts b/src/renderer/debug-harness.ts index e224494..5dfcf33 100644 --- a/src/renderer/debug-harness.ts +++ b/src/renderer/debug-harness.ts @@ -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 { diff --git a/src/renderer/smb/README.md b/src/renderer/smb/README.md index ff9b229..855e4ab 100644 --- a/src/renderer/smb/README.md +++ b/src/renderer/smb/README.md @@ -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` diff --git a/src/renderer/smb/index.ts b/src/renderer/smb/index.ts index 18da691..7c45ca4 100644 --- a/src/renderer/smb/index.ts +++ b/src/renderer/smb/index.ts @@ -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 diff --git a/src/renderer/smb/server.ts b/src/renderer/smb/server.ts index c2d0519..bd98d33 100644 --- a/src/renderer/smb/server.ts +++ b/src/renderer/smb/server.ts @@ -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>(); 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([ - ["_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; 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: `${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: "", 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(); } diff --git a/src/renderer/smb/smb.ts b/src/renderer/smb/smb.ts index 81f46c9..403b04b 100644 --- a/src/renderer/smb/smb.ts +++ b/src/renderer/smb/smb.ts @@ -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 = { [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", diff --git a/src/renderer/smb/test-standalone.ts b/src/renderer/smb/test-standalone.ts index 7d972bc..0a23c35 100644 --- a/src/renderer/smb/test-standalone.ts +++ b/src/renderer/smb/test-standalone.ts @@ -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 diff --git a/src/renderer/smb/wire.ts b/src/renderer/smb/wire.ts index 8db39dc..5f3f25e 100644 --- a/src/renderer/smb/wire.ts +++ b/src/renderer/smb/wire.ts @@ -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); } }