diff --git a/src/main/fileserver/encoding.ts b/src/main/fileserver/encoding.ts deleted file mode 100644 index ea2c7d1..0000000 --- a/src/main/fileserver/encoding.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function encode(text: string) { - // Convert to windows-1252 compatible string by removing unsupported chars - let result = text.replaceAll(/[^\x00-\xFF]/g, ""); - - // If result would be empty, return original - if (!result.trim()) { - return text; - } - - return result; -} - -export function getEncoding() { - return ``; -} diff --git a/src/main/fileserver/fileserver.ts b/src/main/fileserver/fileserver.ts index 13bac82..9df7db9 100644 --- a/src/main/fileserver/fileserver.ts +++ b/src/main/fileserver/fileserver.ts @@ -1,25 +1,16 @@ import { protocol } from "electron"; import * as fs from "fs"; import * as path from "path"; -import { generateDirectoryListing } from "./page-directory-listing"; -import { generateErrorPage } from "./page-error"; import { log } from "../logging"; -export interface FileEntry { - name: string; - fullPath: string; - stats: fs.Stats; -} - -export const APP_INTERCEPT = "http://windows95/"; -export const MY_COMPUTER_INTERCEPT = "http://my-computer/"; - -const interceptedUrls = [MY_COMPUTER_INTERCEPT, APP_INTERCEPT]; +// Serves the bundled static/www site to the guest at http://windows95/. +// Host-filesystem browsing was removed in favour of the SMB share. +const APP_INTERCEPT = "http://windows95/"; +const WWW_ROOT = path.resolve(__dirname, "../../../static/www"); export function setupFileServer() { - // Register protocol handler for our custom schema protocol.handle("http", async (request) => { - if (!interceptedUrls.some((url) => request.url.startsWith(url))) { + if (!request.url.startsWith(APP_INTERCEPT)) { return fetch(request.url, { headers: request.headers, method: request.method, @@ -28,137 +19,46 @@ export function setupFileServer() { } try { - const { fullPath, decodedPath } = getFilePath(request.url); - - log(`FileServer: Handling request for ${request.url}`, { - fullPath, - decodedPath, - }); - - // Check if path exists - if (!fs.existsSync(fullPath)) { - return new Response( - generateErrorPage("File or Directory Not Found", decodedPath), - { - status: 404, - headers: { - "Content-Type": "text/html", - }, - }, - ); + const rel = decodeURIComponent(request.url.slice(APP_INTERCEPT.length)); + let fullPath = path.join(WWW_ROOT, rel); + if (fullPath !== WWW_ROOT && !fullPath.startsWith(WWW_ROOT + path.sep)) { + fullPath = WWW_ROOT; } + log(`FileServer: ${request.url} → ${fullPath}`); - // Check if it's a directory const stats = await fs.promises.stat(fullPath); - if (stats.isDirectory()) { - // If we're in an app-intercept, check if there's an index.htm file in the directory - if (request.url.startsWith(APP_INTERCEPT)) { - const indexHtmlPath = path.join(fullPath, "index.htm"); - if (fs.existsSync(indexHtmlPath)) { - return serveFile(indexHtmlPath); - } - } - - // Generate directory listing - const files = await fs.promises.readdir(fullPath); - const listing = generateDirectoryListing(fullPath, files); - return new Response(listing, { - status: 200, - headers: { - "Content-Type": "text/html", - }, - }); - } else { - try { - return await serveFile(fullPath); - } catch (error) { - // Handle specific file read errors - if ((error as NodeJS.ErrnoException).code === "EACCES") { - return new Response( - generateErrorPage( - "Access Denied", - "You do not have permission to access this file", - ), - { - status: 403, - headers: { - "Content-Type": "text/html", - }, - }, - ); - } - - // Re-throw other errors to be caught by outer try-catch - throw error; - } - } + if (stats.isDirectory()) fullPath = path.join(fullPath, "index.htm"); + return await serveFile(fullPath); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const errorPage = generateErrorPage( - "Internal Server Error", - `An error occurred while processing your request: ${message}`, - ); - return new Response(errorPage, { - status: 500, - headers: { - "Content-Type": "text/html", - }, + const code = (error as NodeJS.ErrnoException).code; + const status = code === "ENOENT" ? 404 : code === "EACCES" ? 403 : 500; + return new Response(`${status} ${code ?? "Error"}: ${request.url}`, { + status, + headers: { "Content-Type": "text/plain" }, }); } }); } -function getFilePath(url: string) { - let urlPath: string; - let fullPath: string; - let decodedPath: string; - - if (url.startsWith(APP_INTERCEPT)) { - fullPath = path.resolve( - __dirname, - "../../../static/www", - url.replace(APP_INTERCEPT, ""), - ); - decodedPath = "."; - } else if (url.startsWith(MY_COMPUTER_INTERCEPT)) { - urlPath = url.replace(MY_COMPUTER_INTERCEPT, ""); - decodedPath = decodeURIComponent(urlPath); - fullPath = path.join("/", decodedPath); - } else { - throw new Error("Invalid URL"); - } - - return { fullPath, decodedPath }; -} +const CONTENT_TYPES: Record = { + ".htm": "text/html", + ".html": "text/html", + ".txt": "text/plain", + ".css": "text/css", + ".js": "text/javascript", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", +}; async function serveFile(fullPath: string): Promise { const fileData = await fs.promises.readFile(fullPath); - - // Determine content type based on file extension const ext = path.extname(fullPath).toLowerCase(); - let contentType = "application/octet-stream"; - - // Common content types - const contentTypes: Record = { - ".htm": "text/html", - ".html": "text/html", - ".txt": "text/plain", - ".css": "text/css", - ".js": "text/javascript", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - }; - - if (ext in contentTypes) { - contentType = contentTypes[ext]; - } - return new Response(fileData, { status: 200, headers: { - "Content-Type": contentType, + "Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream", }, }); } diff --git a/src/main/fileserver/hide-files.ts b/src/main/fileserver/hide-files.ts deleted file mode 100644 index cd96838..0000000 --- a/src/main/fileserver/hide-files.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { settings } from "../settings"; -import { FileEntry } from "./fileserver"; - -const FILES_TO_HIDE_ON_DARWIN: string[] = [ - ".DS_Store", - ".localized", - ".Trashes", - ".fseventsd", - ".Spotlight-V100", - ".file", - ".hotfiles.btree", - ".DocumentRevisions-V100", - ".TemporaryItems", - ".file (resource fork files)", - ".VolumeIcon.icns", -]; - -const FILES_TO_HIDE_ON_WINDOWS: string[] = [ - "desktop.ini", - "Thumbs.db", - "ehthumbs.db", - "ehthumbs.db-shm", - "ehthumbs.db-wal", -]; - -const FILES_TO_HIDE_ON_LINUX: string[] = []; - -export function shouldHideFile(file: FileEntry) { - if (isHiddenFile(file) && !settings.get("isFileServerShowingHiddenFiles")) { - return true; - } - - if ( - isSystemHiddenFile(file) && - !settings.get("isFileServerShowingSystemHiddenFiles") - ) { - return true; - } - - return false; -} - -export function isHiddenFile(file: FileEntry) { - if (process.platform === "win32") { - return (file.stats.mode & 0x2) === 0x2; - } else { - return file.name.startsWith("."); - } -} - -export function isSystemHiddenFile(file: FileEntry) { - return getFilesToHide().some((hiddenFile) => file.name.endsWith(hiddenFile)); -} - -let _filesToHide: string[]; - -function getFilesToHide() { - if (_filesToHide) { - return _filesToHide; - } - - if (process.platform === "darwin") { - _filesToHide = FILES_TO_HIDE_ON_DARWIN; - } else if (process.platform === "win32") { - _filesToHide = FILES_TO_HIDE_ON_WINDOWS; - } else { - _filesToHide = FILES_TO_HIDE_ON_LINUX; - } - - return _filesToHide; -} diff --git a/src/main/fileserver/page-directory-listing.ts b/src/main/fileserver/page-directory-listing.ts deleted file mode 100644 index fee2204..0000000 --- a/src/main/fileserver/page-directory-listing.ts +++ /dev/null @@ -1,126 +0,0 @@ -import path from "path"; -import fs from "fs"; - -import { APP_INTERCEPT, FileEntry, MY_COMPUTER_INTERCEPT } from "./fileserver"; -import { shouldHideFile } from "./hide-files"; -import { encode, getEncoding } from "./encoding"; -import { log } from "console"; -import { app } from "electron"; - -export function generateDirectoryListing( - currentPath: string, - files: string[], -): string { - const parentPath = path.dirname(currentPath || "/"); - const title = - currentPath === "/" - ? "My Host Computer" - : `Directory: ${encode(currentPath)}`; - - // Get file info and sort (directories first, then alphabetically) - const items = files - .map((name) => { - const fullPath = path.join(currentPath, name); - try { - const stats = fs.statSync(fullPath); - return { name, fullPath, stats } as FileEntry; - } catch (error) { - log(`FileServer: Failed to get stats for ${fullPath}: ${error}`); - return null; - } - }) - .filter( - (entry): entry is FileEntry => entry !== null && !shouldHideFile(entry), - ) - .sort((a, b) => { - if (a.stats.isDirectory() !== b.stats.isDirectory()) { - return a.stats.isDirectory() ? -1 : 1; - } - return a.name.localeCompare(b.name); - }) - .map(getFileLiHtml) - .join(""); - - // Generate very simple HTML that works in IE 5.5 - return ` - - - ${getEncoding()} - ${title} - - -

${title}

-

${getParentFolderLinkHtml(parentPath)} | ${getDesktopLinkHtml()} | ${getDownloadsLinkHtml()}

-

-

    - ${items} -
- - - `; -} - -function getParentFolderLinkHtml(parentPath: string) { - return ` - ${getIconHtml("folder.gif")} - - [Parent Directory] - - `; -} - -function getDesktopLinkHtml() { - const desktopPath = app.getPath("desktop"); - - return ` - ${getIconHtml("desktop.gif")} - - Desktop - - `; -} - -function getDownloadsLinkHtml() { - const downloadsPath = app.getPath("downloads"); - - return ` - ${getIconHtml("network.gif")} - - Downloads - - `; -} - -function getIconHtml(icon: string) { - return ``; -} - -function getFileLiHtml(entry: FileEntry) { - const encodedPath = encodeURI(entry.fullPath); - const sizeDisplay = entry.stats.isDirectory() - ? "" - : ` (${formatFileSize(entry.stats.size)})`; - const icon = entry.stats.isDirectory() - ? getIconHtml("folder.gif") - : getIconHtml("doc.gif"); - - return `
  • - ${icon} - - ${getDisplayName(entry)} - - ${sizeDisplay} -
  • `; -} - -function getDisplayName(entry: FileEntry) { - return encode(entry.stats.isDirectory() ? `[${entry.name}]` : entry.name); -} - -function formatFileSize(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; -} diff --git a/src/main/fileserver/page-error.ts b/src/main/fileserver/page-error.ts deleted file mode 100644 index 566c833..0000000 --- a/src/main/fileserver/page-error.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getEncoding } from "./encoding"; -import { MY_COMPUTER_INTERCEPT } from "./fileserver"; - -export function generateErrorPage( - errorMessage: string, - requestedPath: string, -): string { - return ` - - - ${getEncoding()} - Error - File Not Found - - -

    Error: ${errorMessage}

    -

    windows95 failed to find the file or directory on your host computer: ${requestedPath}

    -

    Options:

    - - - - `; -} diff --git a/src/main/settings.ts b/src/main/settings.ts index e35ce25..23277c5 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -3,16 +3,10 @@ import * as path from "path"; import { app } from "electron"; export interface Settings { - isFileServerEnabled: boolean; - isFileServerShowingHiddenFiles: boolean; - isFileServerShowingSystemHiddenFiles: boolean; smbSharePath: string; } const DEFAULT_SETTINGS: Settings = { - isFileServerEnabled: true, - isFileServerShowingHiddenFiles: false, - isFileServerShowingSystemHiddenFiles: false, smbSharePath: app.getPath("downloads"), }; @@ -60,11 +54,6 @@ class SettingsManager { this.save(); } - delete(key: keyof Settings): void { - delete this.data[key]; - this.save(); - } - clear(): void { this.data = DEFAULT_SETTINGS; this.save(); diff --git a/src/renderer/smb/README.md b/src/renderer/smb/README.md index 855e4ab..bb5ea27 100644 --- a/src/renderer/smb/README.md +++ b/src/renderer/smb/README.md @@ -97,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` — 48 protocol tests, full round-trips with real file I/O. +`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` diff --git a/src/renderer/smb/server.ts b/src/renderer/smb/server.ts index bd98d33..393fa07 100644 --- a/src/renderer/smb/server.ts +++ b/src/renderer/smb/server.ts @@ -44,8 +44,9 @@ interface OpenFile { } interface DirEntry { - name: string; // real filename (long) + name: string; // display name (long, sanitized for Win95) sfn: string; // 8.3 name shown to the client + attr: number; // DOS attribute word stat: { isDirectory(): boolean; size: number; mtime: Date }; } @@ -54,9 +55,26 @@ interface SearchState { idx: number; } +const ATTR_HIDDEN = 0x02; +const ATTR_SYSTEM = 0x04; const ATTR_DIRECTORY = 0x10; const ATTR_ARCHIVE = 0x20; +// Host-side junk that shouldn't clutter the guest's view. Marked H+S so +// Explorer hides it by default but "View → Show all files" still works. +const SYSTEM_JUNK = new Set([ + ".ds_store", ".localized", ".trashes", ".fseventsd", ".spotlight-v100", + ".documentrevisions-v100", ".temporaryitems", ".volumeicon.icns", + "desktop.ini", "thumbs.db", "ehthumbs.db", +]); + +function hostAttrs(realName: string, isDir: boolean): number { + let a = isDir ? ATTR_DIRECTORY : ATTR_ARCHIVE; + if (SYSTEM_JUNK.has(realName.toLowerCase())) a |= ATTR_HIDDEN | ATTR_SYSTEM; + else if (realName.startsWith(".")) a |= ATTR_HIDDEN; + return a; +} + // 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. @@ -140,7 +158,7 @@ export class SmbSession { 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) })); + ({ name, sfn: name, attr: ATTR_ARCHIVE, stat: stat(bytes.length) })); } /** @@ -155,6 +173,7 @@ export class SmbSession { this.sfnMaps.set(hostDir, sfnMap); const entries: DirEntry[] = []; + const aliases: [string, string][] = []; for (const [sfn, real] of sfnMap) { try { // The long name we send is single-byte OEM. Anything outside that @@ -162,11 +181,13 @@ export class SmbSession { // 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)) }); + const name = displayName(real); + if (name !== real) aliases.push([name.toUpperCase(), real]); + const stat = fs.statSync(path.join(hostDir, real)); + entries.push({ name, sfn, attr: hostAttrs(real, stat.isDirectory()), stat }); } catch { /* raced — skip */ } } + for (const [k, v] of aliases) sfnMap.set(k, v); return entries; } @@ -446,9 +467,8 @@ export class SmbSession { new Uint8Array(0), new Uint8Array(0)); } const st = fs.statSync(hostPath); - const attrs = st.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE; const words = new Writer() - .u16(attrs) + .u16(hostAttrs(path.basename(hostPath), st.isDirectory())) .u32(unixToSmbTime(st.mtime)) .u32(Math.min(st.size, 0xffffffff)) .zero(10) // reserved @@ -508,13 +528,13 @@ export class SmbSession { 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()); + return this.buildOpenReply(req, cmd, fid, ATTR_ARCHIVE, 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 this.buildOpenReply(req, cmd, fid, ATTR_DIRECTORY, 0, new Date()); } return buildSmb(req, cmd, dosError(ERRDOS, ERR_BADFILE), new Uint8Array(0), new Uint8Array(0)); @@ -531,17 +551,18 @@ export class SmbSession { const isDir = st.isDirectory(); const fd = isDir ? -1 : fs.openSync(hostPath, "r"); this.fids.set(fid, { hostPath, fd, size: st.size, isDir }); - return this.buildOpenReply(req, cmd, fid, isDir, st.size, st.mtime); + return this.buildOpenReply(req, cmd, fid, + hostAttrs(path.basename(hostPath), isDir), st.size, st.mtime); } - private buildOpenReply(req: SmbHeader, cmd: number, fid: number, isDir: boolean, size: number, mtime: Date): Uint8Array { - + private buildOpenReply(req: SmbHeader, cmd: number, fid: number, attrs: number, size: number, mtime: Date): Uint8Array { + const isDir = (attrs & ATTR_DIRECTORY) !== 0; const sz = Math.min(size, 0xffffffff); if (cmd === CMD_OPEN_ANDX) { const words = new Writer() .bytes(andxNone()) .u16(fid) - .u16(isDir ? ATTR_DIRECTORY : ATTR_ARCHIVE) + .u16(attrs) .u32(unixToSmbTime(mtime)) .u32(sz) .u16(0) // GrantedAccess: read @@ -564,7 +585,7 @@ export class SmbSession { .u64(0) // LastAccessTime .u64(0) // LastWriteTime .u64(0) // ChangeTime - .u32(isDir ? ATTR_DIRECTORY : ATTR_ARCHIVE) // ExtFileAttributes + .u32(attrs) // ExtFileAttributes .u64(sz) // AllocationSize .u64(sz) // EndOfFile .u16(0) // FileType: disk @@ -740,8 +761,8 @@ export class SmbSession { // probe and must return exactly the matching file or nothing. if (/[*?]/.test(namePart)) { entries.unshift( - { name: "..", sfn: "..", stat: dotStat }, - { name: ".", sfn: ".", stat: dotStat }, + { name: "..", sfn: "..", attr: ATTR_DIRECTORY, stat: dotStat }, + { name: ".", sfn: ".", attr: ATTR_DIRECTORY, stat: dotStat }, ); } sid = this.nextSid++; @@ -773,7 +794,7 @@ export class SmbSession { out.u8(sid & 0xff).u8(sid >> 8).u8(nextIdx & 0xff).u8(nextIdx >> 8); out.zero(21 - 4); // Attrs - out.u8(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE); + out.u8(e.attr); // Time/Date const dt = unixToDosDateTime(e.stat.mtime); out.u16(dt.time).u16(dt.date); @@ -880,8 +901,8 @@ export class SmbSession { const entries = all.filter(e => matcher(e.sfn) || matcher(e.name)); if (/[*?]/.test(namePart)) { entries.unshift( - { name: "..", sfn: "..", stat: dotStat }, - { name: ".", sfn: ".", stat: dotStat }, + { name: "..", sfn: "..", attr: ATTR_DIRECTORY, stat: dotStat }, + { name: ".", sfn: ".", attr: ATTR_DIRECTORY, stat: dotStat }, ); } @@ -942,7 +963,7 @@ export class SmbSession { data.u64(ft.lo, ft.hi); // ChangeTime data.u64(sz); // EndOfFile data.u64(sz); // AllocationSize - data.u32(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE); + data.u32(e.attr); // 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. @@ -982,7 +1003,7 @@ export class SmbSession { data.u16(dosDate.date).u16(dosDate.time); data.u32(sz); data.u32(sz); - data.u16(e.stat.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE); + data.u16(e.attr); data.u8(e.name.length); lastNameOffset = data.length - entryStart; data.cstr(e.name); @@ -1084,7 +1105,7 @@ export class SmbSession { .u16(dosDate.date).u16(dosDate.time) .u16(dosDate.date).u16(dosDate.time) .u32(sz).u32(sz) - .u16(st.isDirectory() ? ATTR_DIRECTORY : ATTR_ARCHIVE) + .u16(hostAttrs(path.basename(hostPath), st.isDirectory())) .build(); const replyParams = new Writer().u16(0).build(); // EaErrorOffset return this.trans2Reply(req, replyParams, data); @@ -1249,17 +1270,33 @@ 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]/; +// DOS device names. Win95 maps these regardless of extension or directory, +// so a host file called "con.txt" or "AUX" opens the device and hangs the +// redirector. Guarded in both the long name and the generated 8.3 name. +const DOS_RESERVED = /^(CON|PRN|AUX|NUL|CLOCK\$|COM[1-9]|LPT[1-9])$/i; + +/** Map a host filename to something Win95 can safely display and round-trip. */ +function displayName(real: string): string { + // Per-char: single-byte only, no Windows-reserved chars, no controls + // (including DEL/C1). Multi-code-unit chars collapse to one '_'. + const bad = /[<>:"/\\|?*\x00-\x1f\x7f-\x9f]/; let out = ""; - for (const ch of name) { // iterates code points, so 🎨 is one step + for (const ch of real) { // iterates code points, so 🎨 is one step const c = ch.codePointAt(0)!; out += c > 0xff || bad.test(ch) ? "_" : ch; } - return out; + // Win95 silently strips trailing dots/spaces, so "foo." would alias "foo". + // Replace the trailing run so the name stays distinct and length-stable. + out = out.replace(/[. ]+$/, m => "_".repeat(m.length)); + // Reserved device basenames get a suffix so the guest never sees a bare CON. + // Win95 tests the component before the *first* dot and ignores trailing + // spaces there, so "nul.tar.gz" and "con .txt" both need guarding. + const dot = out.indexOf("."); + const base = dot < 0 ? out : out.slice(0, dot); + if (DOS_RESERVED.test(base.replace(/ +$/, ""))) { + out = base + "_" + (dot < 0 ? "" : out.slice(dot)); + } + return out || "_"; } function isRootPath(smbPath: string): boolean { @@ -1365,10 +1402,12 @@ function clean83(s: string): string { /** True if the name already fits 8.3 with no lossy transformation. */ function fits83(name: string): boolean { if (name === "." || name === "..") return true; + if (/[. ]$/.test(name)) return false; const dot = name.lastIndexOf("."); const base = dot > 0 ? name.slice(0, dot) : name; const ext = dot > 0 ? name.slice(dot + 1) : ""; return base.length > 0 && base.length <= 8 && ext.length <= 3 && + !DOS_RESERVED.test(base) && clean83(base).length === base.length && clean83(ext).length === ext.length; } @@ -1399,7 +1438,7 @@ function buildSfnMap(names: string[]): Map { const extRaw = dot > 0 ? real.slice(dot + 1) : ""; const ext = clean83(extRaw).slice(0, 3); let base = clean83(baseRaw); - if (base.length === 0) base = "_"; + if (base.length === 0 || DOS_RESERVED.test(base)) base += "_"; // Windows uses 6 chars + ~N for N<10, then 5+~NN, etc. Good enough. for (let n = 1; ; n++) { diff --git a/src/renderer/smb/test-standalone.ts b/src/renderer/smb/test-standalone.ts index 0a23c35..76e77a4 100644 --- a/src/renderer/smb/test-standalone.ts +++ b/src/renderer/smb/test-standalone.ts @@ -1,6 +1,6 @@ // Standalone test of the SMB stack — no v86, no Electron. Feeds canned // requests through NetBIOSFramer + SmbSession and inspects responses. -// Run: npx ts-node src/renderer/smb/test-standalone.ts +// Run: see src/renderer/smb/README.md for the ts-node invocation. import * as fs from "fs"; import * as path from "path"; @@ -226,6 +226,54 @@ console.log("\n[5b] TRANS2 FIND_FIRST2 level=0x104"); 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(); + 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"); { diff --git a/static/www/apps.htm b/static/www/apps.htm index e18d852..edf04cd 100644 --- a/static/www/apps.htm +++ b/static/www/apps.htm @@ -11,7 +11,7 @@

    I've installed a few apps and games for you to try out. Check out the Games folder on the desktop!

    -

    If you want to try other games, I recommend trying to find them on the Internet Archive. On your host computer, visit https://archive.org, then find the "Classic PC Games" category. Once downloaded, you can import them into windows95 from your host's Download folder.

    +

    If you want to try other games, I recommend trying to find them on the Internet Archive. On your host computer, visit https://archive.org, then find the "Classic PC Games" category. Once downloaded, you can import them into windows95 from the network share — open Network Neighborhood on the desktop (or drive Z: after running \\HOST\TOOLS\_MAPZ.BAT).