Back to Windows95

SMB1 server for Windows 95

src/renderer/smb/README.md

5.0.15.6 KB
Original Source

SMB1 server for Windows 95

Zero-dependency SMB1/CIFS server that lets Windows 95 (running inside v86) mount a host folder as a network drive. Read-only. ~1500 lines.

Stack

LayerFileWhat it does
Ethernet/IP/UDPnbns.tsTaps bus.register("net0-send") for raw frames, parses UDP 137, builds reply frames manually
NetBIOS Name Servicenbns.tsAnswers Node Status (0x21) and Name Query (0x20) — Win95 won't try TCP until this resolves
TCP 139 hookindex.tsMonkeypatches adapter.on_tcp_connection (old v86) or registers tcp-connection bus event (new v86)
NetBIOS Sessionnetbios.tsRFC 1002 framing — 4-byte header, reassembles fragmented TCP
SMB1 wirewire.ts, smb.tsLittle-endian Reader/Writer, header parse/build
Commandsserver.tsNEGOTIATE, SESSION_SETUP, TREE_CONNECT, TRANSACTION (RAP), TRANSACTION2, SEARCH, OPEN, READ, CLOSE, etc.

Protocol gotchas (learned the hard way)

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 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 prepend . and .. like you would for \*, Win95 reads the first entry (., attr=DIRECTORY) and treats FOO.TXT as a folder. Only prepend dots when the pattern contains * or ?.

SEARCH filename: null-terminate before padding

The 13-byte name field must be name\0\0\0..., not name \0. Space-padding before the null means Win95 sees FOO.BAT (with trailing spaces) and can't match the .BAT file association.

8.3 mapping needs ~N suffixes, not just truncation

84 files in a real Downloads folder → most have long names → naive truncation gives 30 copies of 15_UNDER.PDF. Use Windows-style ~N and keep a per-dir SFN→real-name map so OPEN can find the actual file. resolve() walks each path component through the map.

RAP (TRANSACTION 0x25): Win95 loops until ServerGetInfo answers

After TREE_CONNECT \\HOST\IPC$, Win95 sends RAP NetShareEnum (func=0, WrLeh/ B13BWz) then NetWkstaGetInfo (func=63, WrLh/zzzBBzz) then NetServerGetInfo (func=13, WrLh/B16BBDz). The data descriptor tells you the layout: B16 = 16-byte inline name, z = string pointer (4 bytes into a heap that follows the struct), B = byte, D = dword. We synthesize the struct from the descriptor so any info-level Win95 asks for gets a plausible reply.

Virtual files need to be visible to QUERY_INFORMATION too

The injected _MAPZ.BAT showed in listings but Win95 stats before opening, got ERR_BADFILE, said "cannot find". Hook getVirtual() into QUERY_INFO and CHECK_DIRECTORY, not just OPEN.

v86 integration (the hard part)

Old v86 (Feb 2025 — what currently boots): connection theft

The tcp-connection bus event was added later. The old API is adapter.on_tcp_connection(packet, tuple) — you must construct TCPConnection yourself, but it's closure-scoped in Closure-compiled libv86.js. Worse, .on()/.emit()/events_handlers were dead-code-eliminated; the data callback is a flat .on_data property.

The trick: shadow adapter.receive with a no-op (own-prop on a prototype method — must restore via delete, not reassignment), call the original handler with a fake port-80 SYN, take the TCPConnection it builds, re-aim it at port 139. accept(packet) overwrites all routing fields (sport/dport/hsrc/psrc/seq/ ack), .on_data = handler replaces the HTTP callback.

New v86: just bus.register("tcp-connection")

Clean API. The new code keeps both paths; the bus event is a no-op on old builds.

Exception in a bus listener kills the emulator

bus.send doesn't catch listener exceptions. They bubble through ne2k → port_write8 → wasm. Win95 freezes. The corrupted state then gets saved by onbeforeunload. Wrap everything that runs in a callback.

Security

  • Read-only.
  • Path traversal blocked lexically (../) AND through symlinks: realpathSync the deepest existing ancestor, re-append the unresolved tail, confirm under root. Symlinks pointing inside the share still work; symlinks pointing out return ERR_BADFILE.
  • Share path validated in main-process IPC (realpathSync + isDirectory()).

Tests

test-standalone.ts — 55 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