MISC: Harden saving to avoid save data corruption (#2755)

This commit is contained in:
catloversg
2026-05-14 00:24:06 +07:00
committed by GitHub
parent 3acdf019f8
commit 021bcd9351
7 changed files with 70 additions and 8 deletions
+8 -1
View File
@@ -1,5 +1,6 @@
import type { SaveData } from "./types";
import { isSaveData } from "./utils/TypeAssertion";
import { InvalidSaveData } from "./utils/SaveDataUtils";
import { isSaveData, validateSaveData } from "./utils/TypeAssertion";
export class IndexedDBVersionError extends Error {
constructor(message: string, options: ErrorOptions) {
@@ -100,6 +101,12 @@ export async function load(skipCheckingLoadedData = false): Promise<SaveData | u
}
export async function save(saveData: SaveData): Promise<void> {
// Validate save data at runtime. Players may accidentally manipulate the prototypes of built-in JS objects and cause
// functions in SaveObject.ts to generate invalid save data.
const validationResult = validateSaveData(saveData);
if (!validationResult.success) {
throw new InvalidSaveData(validationResult.message);
}
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(["savestring"], "readwrite");
-1
View File
@@ -8,7 +8,6 @@ import { Factions } from "./Faction/Factions";
import { staneksGift } from "./CotMG/Helper";
import { processPassiveFactionRepGain, inviteToFaction } from "./Faction/FactionHelpers";
import { Router } from "./ui/GameRoot";
import "./utils/Protections"; // Side-effect: Protect against certain unrecoverable errors
import "./PersonObjects/Player/PlayerObject"; // For side-effect of creating Player
import {
+2
View File
@@ -1,3 +1,5 @@
import "./utils/Protections"; // Side-effect: Protect against certain unrecoverable errors
import React from "react";
import ReactDOM from "react-dom";
+3 -1
View File
@@ -67,7 +67,9 @@ export function AlertManager({ hidden }: { hidden: boolean }): React.ReactElemen
textPropsAsString = JSON.stringify(text.props);
} catch (e) {
console.error(e);
// Use the current timestamp as the fallback value.
}
// Use the current timestamp as the fallback value.
if (textPropsAsString == null) {
textPropsAsString = Date.now().toString();
}
return cyrb53(textPropsAsString);
+16 -1
View File
@@ -1,5 +1,5 @@
// This file is imported for side effects only.
/* Prevent inadvertantly redefining certain window properties,
/* Prevent inadvertently redefining certain window properties,
which are known to cause unrecoverable game errors when redefined.
The player is able to redefine these properties as writable if desired. */
Object.defineProperties(window, {
@@ -7,3 +7,18 @@ Object.defineProperties(window, {
Object: { writable: false },
String: { writable: false },
});
// Prevent accidentally manipulating IndexedDB APIs.
Object.freeze(IDBFactory.prototype);
Object.freeze(IDBDatabase.prototype);
Object.freeze(IDBTransaction.prototype);
Object.freeze(IDBObjectStore.prototype);
Object.freeze(IDBRequest.prototype);
Object.freeze(IDBOpenDBRequest.prototype);
if (window.indexedDB) {
Object.freeze(window.indexedDB);
Object.defineProperty(window, "indexedDB", {
value: window.indexedDB,
writable: false,
});
}
+13
View File
@@ -35,6 +35,19 @@ async function decompress(binaryData: Uint8Array<ArrayBuffer>): Promise<string>
}
export async function encodeJsonSaveString(jsonSaveString: string): Promise<SaveData> {
if (jsonSaveString == null) {
throw new InvalidSaveData(`jsonSaveString is ${jsonSaveString}`);
}
if (typeof jsonSaveString !== "string") {
console.error(jsonSaveString);
throw new InvalidSaveData(`Type of jsonSaveString is ${typeof jsonSaveString}`);
}
if (!jsonSaveString.startsWith(`{"ctor":"BitburnerSaveObject"`)) {
console.error(jsonSaveString);
throw new InvalidSaveData(
`Invalid jsonSaveString (doesn't seem to contain a BitburnerSaveObject): ${jsonSaveString.slice(0, 100)}`,
);
}
// Fallback to the base64 format if player's browser does not support Compression Streams API.
if (canUseBinaryFormat()) {
return await compress(jsonSaveString);
+28 -4
View File
@@ -1,4 +1,5 @@
import type { SaveData, Unknownify } from "../types";
import type { Result } from "@nsdefs";
// This function is empty because Unknownify<T> is a typesafe assertion on any object with no runtime checks needed.
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -90,12 +91,35 @@ export function assertNumberArray(unknownData: unknown, assertFinite = false): a
}
}
export function isSaveData(unknownData: unknown): unknownData is SaveData {
if (typeof unknownData === "string") {
return true;
export function validateSaveData(unknownData: unknown): Result {
if (unknownData == null) {
return { success: false, message: `Save data is ${unknownData}` };
}
return unknownData instanceof Uint8Array && unknownData.buffer instanceof ArrayBuffer;
if (unknownData === "") {
return { success: false, message: "Save data is an empty string" };
}
if (typeof unknownData === "string") {
return { success: true };
}
if (!(unknownData instanceof Uint8Array)) {
console.error(unknownData);
return { success: false, message: "Save data is not an instance of Uint8Array" };
}
if (unknownData.length === 0) {
return { success: false, message: "Save data is an empty Uint8Array" };
}
if (!(unknownData.buffer instanceof ArrayBuffer)) {
console.error(unknownData.buffer);
return { success: false, message: "Save data is a Uint8Array, but its buffer is not an ArrayBuffer" };
}
return { success: true };
}
export function isSaveData(unknownData: unknown): unknownData is SaveData {
return validateSaveData(unknownData).success;
}
export function assertSaveData(unknownData: unknown): asserts unknownData is SaveData {