// Standalone test of the SMB stack — no v86, no Electron. Feeds canned
// requests through NetBIOSFramer + SmbSession and inspects responses.
// Run: see src/renderer/smb/README.md for the ts-node invocation.

import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { NetBIOSFramer, nbWrap } from "./netbios";
import { SmbSession } from "./server";
import { parseSmb, CMD_NEGOTIATE, CMD_SESSION_SETUP_ANDX,
         CMD_TREE_CONNECT_ANDX, CMD_TRANSACTION2, CMD_OPEN_ANDX,
         CMD_READ_ANDX, CMD_CLOSE } from "./smb";

let pass = 0, fail = 0;
const ok = (cond: boolean, msg: string) => {
  if (cond) { pass++; console.log("  ✓", msg); }
  else { fail++; console.log("  ✗", msg); }
};

// @ts-ignore — kept for debugging when tests fail
const hex = (b: Uint8Array, n = 32) =>
  Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, "0")).join(" ");
void hex;

// ─── Build a minimal SMB request from scratch ────────────────────────────────
function smbReq(cmd: number, words: number[], bytes: number[],
                tid = 0, uid = 0, mid = 1): Uint8Array {
  const out: number[] = [];
  out.push(0xff, 0x53, 0x4d, 0x42);     // magic
  out.push(cmd);                         // cmd
  out.push(0, 0, 0, 0);                  // status
  out.push(0x18);                        // flags (caseless+canonical)
  out.push(0x01, 0x00);                  // flags2: long names, no unicode
  for (let i = 0; i < 12; i++) out.push(0); // reserved
  out.push(tid & 0xff, tid >> 8);
  out.push(0, 0);                        // pid
  out.push(uid & 0xff, uid >> 8);
  out.push(mid & 0xff, mid >> 8);
  if (words.length % 2) throw new Error("words must be even");
  out.push(words.length / 2);
  out.push(...words);
  out.push(bytes.length & 0xff, bytes.length >> 8);
  out.push(...bytes);
  return new Uint8Array(out);
}

const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
const u32 = (v: number) => [...u16(v & 0xffff), ...u16((v >>> 16) & 0xffff)];
const cstr = (s: string) => [...Buffer.from(s, "ascii"), 0];

// ─── Setup test fixture ──────────────────────────────────────────────────────
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-"));
fs.writeFileSync(path.join(tmpRoot, "hello.txt"), "Hello from the host!\n");
fs.mkdirSync(path.join(tmpRoot, "subdir"));
fs.writeFileSync(path.join(tmpRoot, "subdir", "nested.dat"), Buffer.alloc(100, 0xAB));
console.log("fixture:", tmpRoot);

const session = new SmbSession(tmpRoot);
session.capture = false;

// ─── Test 1: NetBIOS framing ─────────────────────────────────────────────────
console.log("\n[1] NetBIOS framer");
{
  const framer = new NetBIOSFramer();
  // Session request: type 0x81, len 68 (called name 34 + calling name 34)
  const sessReq = new Uint8Array([0x81, 0, 0, 68, ...new Array(68).fill(0x20)]);
  const msgs1 = framer.push(sessReq);
  ok(msgs1.length === 1 && msgs1[0].type === 0x81, "parses session request");

  // Fragmented session message
  const payload = new Uint8Array([0xff, 0x53, 0x4d, 0x42, 0x72, 0, 0, 0, 0, 0]);
  const wrapped = nbWrap(payload);
  const msgs2 = framer.push(wrapped.slice(0, 5));
  ok(msgs2.length === 0, "incomplete frame buffers");
  const msgs3 = framer.push(wrapped.slice(5));
  ok(msgs3.length === 1 && msgs3[0].type === 0x00, "completes on second chunk");
  ok(msgs3[0].type === 0x00 && msgs3[0].payload[0] === 0xff && msgs3[0].payload[1] === 0x53,
     "payload extracted");
}

// ─── Test 2: NEGOTIATE ───────────────────────────────────────────────────────
console.log("\n[2] NEGOTIATE");
{
  // Real Win95 dialect list (abbreviated). Each entry is 0x02 + cstr.
  const dialects = ["PC NETWORK PROGRAM 1.0", "LANMAN1.0", "LM1.2X002",
                    "LANMAN2.1", "NT LM 0.12"];
  const bytes: number[] = [];
  for (const d of dialects) { bytes.push(0x02); bytes.push(...cstr(d)); }

  const req = smbReq(CMD_NEGOTIATE, [], bytes);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.cmd === CMD_NEGOTIATE, "cmd echoed");
  ok((parsed.flags & 0x80) !== 0, "reply flag set");
  ok(parsed.status === 0, "status OK");
  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 === 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 ───────────────────────────────────────────────────
console.log("\n[3] SESSION_SETUP_ANDX");
{
  // Minimal setup: AndX(4) MaxBuf(2) MaxMpx(2) VcNum(2) SessKey(4)
  // PwLen(2) Reserved(4) — bytes: password + account + domain + os + lanman
  const words = [0xff, 0, 0, 0, ...u16(4096), ...u16(1), ...u16(0),
                 ...u32(0), ...u16(0), ...u32(0)];
  const bytes = [...cstr(""), ...cstr("GUEST"), ...cstr("WORKGROUP"),
                 ...cstr("Windows 4.0"), ...cstr("Windows 4.0")];
  const req = smbReq(CMD_SESSION_SETUP_ANDX, words, bytes);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "status OK");
  ok(parsed.uid === 1, `assigned uid=${parsed.uid}`);
  // Action word at offset 4 (after AndX) = guest bit
  const action = parsed.words[4] | (parsed.words[5] << 8);
  ok((action & 1) === 1, "guest bit set");
}

// ─── Test 4: TREE_CONNECT ────────────────────────────────────────────────────
console.log("\n[4] TREE_CONNECT_ANDX");
{
  const words = [0xff, 0, 0, 0, ...u16(0), ...u16(1)]; // pwLen=1
  const bytes = [0, ...cstr("\\\\192.168.86.1\\HOST"), ...cstr("?????")];
  const req = smbReq(CMD_TREE_CONNECT_ANDX, words, bytes, 0, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "status OK");
  ok(parsed.tid === 1, `assigned tid=${parsed.tid}`);
  // bytes should start with "A:\0"
  const svc = String.fromCharCode(parsed.bytes[0], parsed.bytes[1]);
  ok(svc === "A:", `service="${svc}"`);
}

// ─── Test 5: TRANS2 FIND_FIRST2 (directory listing) ──────────────────────────
console.log("\n[5] TRANS2 FIND_FIRST2");
{
  // TRANS2 setup is gnarly. Build from spec:
  // params: SearchAttrs(2) SearchCount(2) Flags(2) InfoLevel(2) Storage(4) "\*"\0
  const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(1),
                    ...u32(0), ...cstr("\\*")];
  // setup word = TRANS2_FIND_FIRST2 (1)
  // word block: TotPrm(2) TotData(2) MaxPrm(2) MaxData(2) MaxSetup(1) Rsvd(1)
  //   Flags(2) Timeout(4) Rsvd(2) PrmCnt(2) PrmOff(2) DataCnt(2) DataOff(2)
  //   SetupCnt(1) Rsvd(1) Setup[0](2)
  const wc = 14 + 1; // 14 fixed + 1 setup
  const bytesStart = 32 + 1 + wc * 2 + 2;
  const paramOff = bytesStart + 3; // 3 bytes pad ("\0\0\0") before params
  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) // SetupCount=1, Setup[0]=FIND_FIRST2
  ];
  const bytes = [0, 0, 0, ...t2params]; // 3-byte name padding + params
  const req = smbReq(CMD_TRANSACTION2, words, bytes, 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "status OK");
  // Reply params: SID(2) Count(2) EOS(2) EaErr(2) LastName(2)
  // Reply words tell us where params live
  const rw = parsed.words;
  const replyParamOffset = rw[8] | (rw[9] << 8);
  const replyParamCount = rw[6] | (rw[7] << 8);
  const replyBytesStart = 32 + 1 + parsed.wordCount * 2 + 2;
  const pStart = replyParamOffset - replyBytesStart;
  const replyParams = parsed.bytes.slice(pStart, pStart + replyParamCount);
  const searchCount = replyParams[2] | (replyParams[3] << 8);
  // 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"), "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 5d: filename safety + hidden attrs ─────────────────────────────────
console.log("\n[5d] filename safety");
{
  const hz = path.join(tmpRoot, "hazard");
  fs.mkdirSync(hz);
  for (const n of ["con.txt", "aux", "nul.tar.gz", ".DS_Store", ".secret", "trail. "])
    fs.writeFileSync(path.join(hz, n), "x");

  const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(0x104),
                    ...u32(0), ...cstr("\\hazard\\*")];
  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 reply = session.handle(smbReq(CMD_TRANSACTION2, words,
                                      [0, 0, 0, ...t2params], 1, 1))!;
  const parsed = parseSmb(reply)!;
  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 ents = new Map<string, number>();
  for (let off = 0;;) {
    const next = data[off] | (data[off+1]<<8) | (data[off+2]<<16) | (data[off+3]<<24);
    const attr = data[off+56] | (data[off+57]<<8);
    const fnLen = data[off+60] | (data[off+61]<<8);
    const nm = String.fromCharCode(...data.slice(off+94, off+94+fnLen)).replace(/\0$/, "");
    ents.set(nm, attr);
    if (next === 0) break;
    off += next;
  }
  const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
  const bad = [...ents.keys()].filter(n => reserved.test(n.split(".")[0]));
  ok(bad.length === 0, `no reserved basenames: ${JSON.stringify([...ents.keys()])}`);
  ok(ents.has("con_.txt") && ents.has("aux_"), "reserved names suffixed");
  ok(ents.has("nul_.tar.gz"), "reserved across multi-ext");
  ok(ents.has("trail__"), "trailing dot/space replaced");
  ok((ents.get(".DS_Store")! & 0x06) === 0x06, ".DS_Store hidden+system");
  ok((ents.get(".secret")! & 0x02) === 0x02, "dotfile hidden");
  ok((ents.get("con_.txt")! & 0x02) === 0, "regular file not hidden");
}

// ─── 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;
{
  // OPEN_ANDX words: AndX(4) Flags(2) Access(2) SrchAttr(2) FileAttr(2)
  //   CreateTime(4) OpenFunc(2) AllocSize(4) Timeout(4) Rsvd(4)
  const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
                 ...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
  const bytes = [...cstr("\\hello.txt")];
  const req = smbReq(CMD_OPEN_ANDX, words, bytes, 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "open status OK");
  openedFid = parsed.words[4] | (parsed.words[5] << 8); // FID after AndX
  ok(openedFid > 0, `fid=${openedFid}`);
  // OPEN_ANDX response: AndX(4) FID(2) Attrs(2) LastWrite(4) DataSize(4) ...
  const fileSize = parsed.words[12] | (parsed.words[13] << 8) |
                   (parsed.words[14] << 16) | (parsed.words[15] << 24);
  ok(fileSize === 21, `size=${fileSize} (expect 21)`);
}
{
  // READ_ANDX: AndX(4) FID(2) Offset(4) MaxCount(2) MinCount(2)
  //   Timeout(4) Remaining(2) [OffsetHigh(4)]
  const words = [0xff, 0, 0, 0, ...u16(openedFid), ...u32(0), ...u16(100),
                 ...u16(0), ...u32(0), ...u16(0)];
  const req = smbReq(CMD_READ_ANDX, words, [], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "read status OK");
  const dataLen = parsed.words[10] | (parsed.words[11] << 8);
  ok(dataLen === 21, `read ${dataLen} bytes`);
  // bytes = pad(1) + data
  const text = String.fromCharCode(...parsed.bytes.slice(1, 1 + dataLen));
  ok(text === "Hello from the host!\n", `content: ${JSON.stringify(text)}`);
}
{
  const words = [...u16(openedFid), ...u32(0)];
  const req = smbReq(CMD_CLOSE, words, [], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "close status OK");
}

// ─── Test 7: error paths ─────────────────────────────────────────────────────
console.log("\n[7] Error handling");
{
  const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
                 ...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
  const req = smbReq(CMD_OPEN_ANDX, words, [...cstr("\\nope.txt")], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status !== 0, `nonexistent file → status=0x${parsed.status.toString(16)}`);
  // DOS error: class=1 (ERRDOS), code=2 (badfile)
  ok((parsed.status & 0xff) === 1 && (parsed.status >> 16) === 2, "ERRDOS/ERR_badfile");
}
{
  const req = 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("\\..\\..\\etc\\passwd")], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status !== 0, "lexical traversal (../) blocked");
}
{
  // 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")], 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)], [], 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))}`);

  // READ_RAW (0x1a): response is raw bytes, no SMB header.
  const raw = session.handle(smbReq(0x1a,
    [...u16(fid2), ...u32(0), ...u16(65535), ...u16(0), ...u32(0), ...u16(0)],
    [], tcParsed.tid, 1))!;
  ok(raw.length === seekPos && raw[0] === 0x77 /* 'w' */,
     `READ_RAW returned ${raw.length} raw bytes (no SMB header)`);
  const rawBad = session.handle(smbReq(0x1a,
    [...u16(0x7777), ...u32(0), ...u16(100), ...u16(0), ...u32(0), ...u16(0)],
    [], tcParsed.tid, 1))!;
  ok(rawBad.length === 0, "READ_RAW bad fid → 0-byte reply");
}
{
  // symlink escape: link inside share → file outside share
  const outside = path.join(os.tmpdir(), "smbtest-secret.txt");
  fs.writeFileSync(outside, "leaked");
  fs.symlinkSync(outside, path.join(tmpRoot, "evil"));

  const req = 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("\\evil")], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status !== 0, "symlink escape blocked");

  fs.unlinkSync(outside);
}
{
  // symlink directory escape: link inside share → dir outside, then walk into it
  const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-out-"));
  fs.writeFileSync(path.join(outsideDir, "secret.txt"), "leaked");
  fs.symlinkSync(outsideDir, path.join(tmpRoot, "evildir"));

  const req = 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("\\evildir\\secret.txt")], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status !== 0, "symlink dir escape blocked");

  fs.rmSync(outsideDir, { recursive: true });
}
{
  // symlink that stays INSIDE the share should still work
  fs.symlinkSync(path.join(tmpRoot, "hello.txt"), path.join(tmpRoot, "alias"));
  const req = 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("\\alias")], 1, 1);
  const reply = session.handle(req)!;
  const parsed = parseSmb(reply)!;
  ok(parsed.status === 0, "internal symlink allowed");
}

// ─── Cleanup ─────────────────────────────────────────────────────────────────
session.destroy();
fs.rmSync(tmpRoot, { recursive: true });

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
