mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 00:24:09 +00:00
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:
committed by
Felix Rieseberg
parent
9b217731f5
commit
1dbb853fe6
@@ -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">`;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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");
|
||||
{
|
||||
|
||||
@@ -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 — 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>
|
||||
|
||||
Reference in New Issue
Block a user