diff --git a/src/DarkNet/models/DarknetServerOptions.ts b/src/DarkNet/models/DarknetServerOptions.ts index ccdce54ca..2d4e4d984 100644 --- a/src/DarkNet/models/DarknetServerOptions.ts +++ b/src/DarkNet/models/DarknetServerOptions.ts @@ -18,6 +18,7 @@ import { hasFullDarknetAccess } from "../effects/effects"; import { getFriendlyType, TypeAssertionError } from "../../utils/TypeAssertion"; import { isIPAddress } from "../../Types/strings"; import { roundToTwo } from "../../utils/helpers/roundToTwo"; +import { safelyReverseString } from "../../utils/StringHelperFunctions"; export type PasswordResponse = { code: DarknetResponseCode; @@ -158,11 +159,11 @@ const decorateName = (name: string): string => { const connector = connectors[Math.floor(Math.random() * connectors.length)]; if (Math.random() < 0.3) { - updatedName = l33tifyName(name); + updatedName = l33tifyName(updatedName); } if (Math.random() < 0.05) { - updatedName = updatedName.split("").reverse().join(""); + updatedName = safelyReverseString(updatedName); } if (Math.random() < 0.1) { @@ -180,7 +181,11 @@ const decorateName = (name: string): string => { } } while (GetServer(updatedName) !== null); - return updatedName; + // Defensive coding. All operations above preserve well-formed UTF-16, so this is currently redundant. It's a + // safeguard to ensure the function never returns ill-formed UTF-16 if future changes introduce code unit–level + // manipulation. + // This normalization is lossy (lone surrogates -> U+FFFD). + return updatedName.toWellFormed(); }; const l33tifyName = (name: string): string => { @@ -191,7 +196,11 @@ const l33tifyName = (name: string): string => { const replacement: string = l33t[char] ?? ""; updatedName = updatedName.replaceAll(char, replacement); } - return updatedName; + // Defensive coding. All operations above preserve well-formed UTF-16, so this is currently redundant. It's a + // safeguard to ensure the function never returns ill-formed UTF-16 if future changes introduce code unit–level + // manipulation. + // This normalization is lossy (lone surrogates -> U+FFFD). + return updatedName.toWellFormed(); }; const getMaxRam = (difficulty: number): number => { diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index 02e8a968c..f3ac8abf7 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -212,6 +212,12 @@ export class PlayerObject extends Person implements IPlayer { delete player.jobs[loadedCompanyName as CompanyName]; } } + // A bug created ill-formed UTF-16 darknet hostnames that caused the in-game editor to crash. Player.currentServer + // may point to one of these invalid hostnames. This code migrates the invalid hostnames and protects against + // similar issues in the future. + if (!player.currentServer.isWellFormed()) { + player.currentServer = player.currentServer.toWellFormed(); + } return player; } } diff --git a/src/Server/AllServers.ts b/src/Server/AllServers.ts index 24720489e..a2116ce8d 100644 --- a/src/Server/AllServers.ts +++ b/src/Server/AllServers.ts @@ -148,6 +148,25 @@ export function loadAllServers(saveString: string): void { if (!(server instanceof Server) && !(server instanceof HacknetServer) && !(server instanceof DarknetServer)) { throw new Error(`Server ${serverName} is not an instance of Server or HacknetServer or DarknetServer.`); } + // Sanitize hostname + // A bug created ill-formed UTF-16 darknet hostnames that caused the in-game editor to crash. This code migrates + // those invalid hostnames and protects against similar issues in the future. + if (!server.hostname.isWellFormed()) { + server.hostname = server.hostname.toWellFormed(); + for (const script of server.scripts.values()) { + script.server = server.hostname; + } + if (server.savedScripts) { + for (const script of server.savedScripts) { + script.server = server.hostname; + } + } + } + // Sanitize hostnames in server.serversOnNetwork + for (const [index, value] of server.serversOnNetwork.entries()) { + server.serversOnNetwork[index] = value.toWellFormed(); + } + AllServers.set(server.hostname, server); AllServers.set(server.ip, server); } diff --git a/src/utils/ErrorHelper.ts b/src/utils/ErrorHelper.ts index 63d9494f3..5d6a5729a 100644 --- a/src/utils/ErrorHelper.ts +++ b/src/utils/ErrorHelper.ts @@ -186,7 +186,9 @@ Copy your save here if possible \`\`\` `.trim(); - const issueUrl = `${newIssueUrl}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; + const issueUrl = `${newIssueUrl}?title=${encodeURIComponent(title.toWellFormed())}&body=${encodeURIComponent( + body.toWellFormed(), + )}`; return { metadata, diff --git a/src/utils/StringHelperFunctions.ts b/src/utils/StringHelperFunctions.ts index 5991972e6..ee7496242 100644 --- a/src/utils/StringHelperFunctions.ts +++ b/src/utils/StringHelperFunctions.ts @@ -105,3 +105,18 @@ export function getKeyFromReactElements(a: string | React.JSX.Element, b: string const keyOfb = typeof b === "string" ? b : b.key ?? ""; return keyOfA + keyOfb; } + +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** + * input.split("") operates on UTF-16 code units and can break surrogate pairs. + * For example, 'a🅱️b' is 'a\uD83C\uDD71\uFE0Fb'. A naive reverse yields 'b\uFE0F\uDD71\uD83Ca', which is ill-formed + * UTF-16 and not 'b🅱️a' as expected. + * Passing such a string to encodeURIComponent may throw a URIError (e.g. in Monaco editor code when processing model + * ids). + */ +export function safelyReverseString(input: string): string { + return Array.from(graphemeSegmenter.segment(input), (s) => s.segment) + .reverse() + .join(""); +} diff --git a/test/jest/Darknet/Darknet.test.ts b/test/jest/Darknet/Darknet.test.ts index 19f58f2c5..9fe4246f0 100644 --- a/test/jest/Darknet/Darknet.test.ts +++ b/test/jest/Darknet/Darknet.test.ts @@ -780,6 +780,7 @@ describe("mutateDarknet and webstorm", () => { function validatePath(hostname: string): void { expectWithMessage(isDirectoryPath(`${hostname}/`), true, `Invalid hostname: ${hostname}`); expectWithMessage(isFilePath(`${hostname}/data.txt`), true, `Invalid hostname: ${hostname}`); + expectWithMessage(hostname.isWellFormed(), true, `Malformed hostname: ${hostname}`); } describe("Darknet server name generator", () => { diff --git a/test/jest/Migration/Migration.test.ts b/test/jest/Migration/Migration.test.ts index 6d78f02c8..28e9a4876 100644 --- a/test/jest/Migration/Migration.test.ts +++ b/test/jest/Migration/Migration.test.ts @@ -6,6 +6,7 @@ import * as db from "../../../src/db"; import * as FileUtils from "../../../src/utils/FileUtils"; import type { SaveData } from "../../../src/types"; import { calculateExp } from "../../../src/PersonObjects/formulas/skill"; +import { GetAllServers, GetServer } from "../../../src/Server/AllServers"; async function loadGameFromSaveData(saveData: SaveData) { // Simulate loading the data in IndexedDB @@ -132,4 +133,25 @@ describe("v3", () => { expect(mockedDownload).not.toHaveBeenCalled(); }); }); + + test("Malformed hostname", async () => { + const saveData = new Uint8Array(fs.readFileSync("test/jest/Migration/save-files/malformed-hostname.gz")); + await loadGameFromSaveData(saveData); + for (const server of GetAllServers(true)) { + expect(server.hostname.isWellFormed()).toBe(true); + for (const script of server.scripts.values()) { + expect(script.server).toStrictEqual(server.hostname); + } + if (server.savedScripts) { + for (const script of server.savedScripts) { + expect(script.server).toStrictEqual(server.hostname); + } + } + for (const hostname of server.serversOnNetwork) { + expect(hostname.isWellFormed()).toBe(true); + expect(GetServer(hostname)).not.toBeNull(); + } + } + expect(() => Player.getCurrentServer()).not.toThrow(); + }); }); diff --git a/test/jest/Migration/save-files/malformed-hostname.gz b/test/jest/Migration/save-files/malformed-hostname.gz new file mode 100644 index 000000000..a5decabb5 Binary files /dev/null and b/test/jest/Migration/save-files/malformed-hostname.gz differ