SMB: filename safety + hidden attrs; drop http://my-computer host browser (#354)

SMB share:
- Mark dotfiles HIDDEN and host junk (.DS_Store, Thumbs.db, ...) HIDDEN+SYSTEM
  so Explorer hides them by default but View > Show all files still works.
- Guard DOS device names (CON/PRN/AUX/NUL/CLOCK$/COM1-9/LPT1-9) in both the
  long name and the generated 8.3 name; covers nul.tar.gz and 'con .txt' too.
- Replace trailing dot/space runs with '_' so names don't alias their stripped
  form; block DEL and C1 control bytes.
- Fix duplicate listing of sanitized names (sfnMap was mutated mid-iteration).
- Thread attrs through OPEN/QUERY/FIND replies via DirEntry.attr / hostAttrs().
- New [5d] section in test-standalone.ts; 55 tests pass.

http file server:
- Remove the http://my-computer/ host-filesystem browser now that SMB covers
  folder sharing. fileserver.ts now serves only static/www at http://windows95/
  with a sep-suffixed traversal guard.
- Delete page-directory-listing.ts, page-error.ts, encoding.ts, hide-files.ts
  and the three isFileServer* settings keys (and unused SettingsManager.delete).
- Point static/www/apps.htm at the SMB share instead.
This commit is contained in:
Felix Rieseberg
2026-04-11 13:08:29 -07:00
committed by Felix Rieseberg
parent 9b217731f5
commit 1dbb853fe6
10 changed files with 149 additions and 410 deletions

View File

@@ -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 `<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">`;
}

View File

@@ -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,118 +19,28 @@ 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 {
if (stats.isDirectory()) fullPath = path.join(fullPath, "index.htm");
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;
}
}
} 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 };
}
async function serveFile(fullPath: string): Promise<Response> {
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<string, string> = {
const CONTENT_TYPES: Record<string, string> = {
".htm": "text/html",
".html": "text/html",
".txt": "text/plain",
@@ -149,16 +50,15 @@ async function serveFile(fullPath: string): Promise<Response> {
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
};
if (ext in contentTypes) {
contentType = contentTypes[ext];
}
};
async function serveFile(fullPath: string): Promise<Response> {
const fileData = await fs.promises.readFile(fullPath);
const ext = path.extname(fullPath).toLowerCase();
return new Response(fileData, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
},
});
}

View File

@@ -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;
}

View File

@@ -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 `
<html>
<head>
${getEncoding()}
<title>${title}</title>
</head>
<body>
<h2>${title}</h2>
<p>${getParentFolderLinkHtml(parentPath)} | ${getDesktopLinkHtml()} | ${getDownloadsLinkHtml()}</p>
<p>
<ul>
${items}
</ul>
</body>
</html>
`;
}
function getParentFolderLinkHtml(parentPath: string) {
return `
${getIconHtml("folder.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(parentPath)}">
[Parent Directory]
</a>
`;
}
function getDesktopLinkHtml() {
const desktopPath = app.getPath("desktop");
return `
${getIconHtml("desktop.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(desktopPath)}">
Desktop
</a>
`;
}
function getDownloadsLinkHtml() {
const downloadsPath = app.getPath("downloads");
return `
${getIconHtml("network.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(downloadsPath)}">
Downloads
</a>
`;
}
function getIconHtml(icon: string) {
return `<img src="${APP_INTERCEPT}images/${icon}" style="vertical-align: middle; margin-right: 5px;" width="16" height="16">`;
}
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 `<li>
${icon}
<a href="${MY_COMPUTER_INTERCEPT}${encodedPath}">
${getDisplayName(entry)}
</a>
${sizeDisplay}
</li>`;
}
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];
}

View File

@@ -1,25 +0,0 @@
import { getEncoding } from "./encoding";
import { MY_COMPUTER_INTERCEPT } from "./fileserver";
export function generateErrorPage(
errorMessage: string,
requestedPath: string,
): string {
return `
<html>
<head>
${getEncoding()}
<title>Error - File Not Found</title>
</head>
<body>
<h2>Error: ${errorMessage}</h2>
<p>windows95 failed to find the file or directory on your host computer: <code>${requestedPath}</code></p>
<p>Options:</p>
<ul>
<li><a href="${MY_COMPUTER_INTERCEPT}">Return to root directory</a></li>
<li><a href="javascript:history.back()">Go back to previous page</a></li>
</ul>
</body>
</html>
`;
}

View File

@@ -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();

View File

@@ -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`

View File

@@ -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: `<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());
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: "<tools>", 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<string, string> {
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++) {

View File

@@ -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<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");
{

View File

@@ -11,7 +11,7 @@
<hr>
<p>I've installed a few apps and games for you to try out. Check out the Games folder on the desktop!</p>
<p>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 <a href="http://my-computer">your host's Download folder</a>.</p>
<p>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 &mdash; open <b>Network Neighborhood</b> on the desktop (or drive <b>Z:</b> after running <code>\\HOST\TOOLS\_MAPZ.BAT</code>).</p>
</font>
</td>
</tr>