mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 00:24:09 +00:00
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:
@@ -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.",
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user