mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Validate imported player data before storing it
An import with an unknown controller or malformed inventories replaced the stored snapshot and bumped its epoch, then broke the player's next join. SetPlayerDataRequest now checks the fields the scenario restores, leaving item stacks to the Lua deserializer and allowing the partial snapshots instances with disabled components upload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ad5be0ff6
commit
c260baaf03
2 files changed
+95
-1
No files matched your search
@@ -34,6 +34,49 @@ const jsonPlayerData = Type.Object({
|
||||
name: Type.String(),
|
||||
});
|
||||
|
||||
// The parts of an import that module/serialize.lua relies on, item stacks are checked by the Lua deserializer
|
||||
const jsonInventory = Type.Object({
|
||||
b: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
// An empty Lua table is encoded as an object
|
||||
i: Type.Union([
|
||||
Type.Array(Type.Object({
|
||||
s: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
r: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
})),
|
||||
Type.Object({}, { maxProperties: 0 }),
|
||||
]),
|
||||
});
|
||||
const jsonInventories = Type.Record(Type.String(), jsonInventory);
|
||||
const jsonColor = Type.Union([
|
||||
Type.Object({ r: Type.Number(), g: Type.Number(), b: Type.Number(), a: Type.Optional(Type.Number()) }),
|
||||
Type.Array(Type.Number(), { minItems: 3, maxItems: 4 }),
|
||||
]);
|
||||
const jsonTable = Type.Union([Type.Array(Type.Unknown()), Type.Object({})]);
|
||||
const jsonImportedPlayerData = Type.Object({
|
||||
generation: Type.Integer({ minimum: 0 }),
|
||||
epoch: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
name: Type.String(),
|
||||
// Any physical controller, remote view is never stored
|
||||
controller: Type.Optional(StringEnum(["character", "god", "editor", "cutscene", "spectator", "ghost"])),
|
||||
color: Type.Optional(jsonColor),
|
||||
chat_color: Type.Optional(jsonColor),
|
||||
tag: Type.Optional(Type.String()),
|
||||
force: Type.Optional(Type.String()),
|
||||
cheat_mode: Type.Optional(Type.Boolean()),
|
||||
flashlight: Type.Optional(Type.Boolean()),
|
||||
shortcuts: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
|
||||
game_view_settings: Type.Optional(Type.Record(Type.String(), Type.Boolean())),
|
||||
ticks_to_respawn: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
character: Type.Optional(Type.Object({ inventories: Type.Optional(jsonInventories) })),
|
||||
inventories: Type.Optional(jsonInventories),
|
||||
quick_bar: Type.Optional(jsonTable),
|
||||
hotbar: Type.Optional(jsonTable),
|
||||
personal_logistic_slots: Type.Optional(jsonTable),
|
||||
crafting_queue: Type.Optional(jsonTable),
|
||||
recipe_notifications: Type.Optional(Type.String()),
|
||||
components: Type.Optional(jsonTable),
|
||||
});
|
||||
|
||||
export class AcquireResponse {
|
||||
constructor(
|
||||
public status: string,
|
||||
@@ -302,7 +345,7 @@ export class SetPlayerDataRequest {
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"playerName": Type.String(),
|
||||
"playerData": jsonPlayerData,
|
||||
"playerData": jsonImportedPlayerData,
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof SetPlayerDataRequest.jsonSchema>) {
|
||||
|
||||
@@ -51,6 +51,57 @@ describe("player_sync plugin", function() {
|
||||
assert(!validate(request), `accepted ${generation}`);
|
||||
}
|
||||
});
|
||||
describe("SetPlayerDataRequest schema", function() {
|
||||
const validate = lib.compile(msg.SetPlayerDataRequest.jsonSchema);
|
||||
// Shaped like module/serialize.lua output
|
||||
const exported = playerData("player", 7, {
|
||||
epoch: 2,
|
||||
color: { r: 1, g: 0.5, b: 0, a: 0.5 },
|
||||
chat_color: { r: 1, g: 0.5, b: 0, a: 1 },
|
||||
tag: "",
|
||||
cheat_mode: false,
|
||||
flashlight: true,
|
||||
shortcuts: { "toggle-personal-roboport": true },
|
||||
game_view_settings: { show_minimap: true },
|
||||
character: {
|
||||
character_running_speed_modifier: 0,
|
||||
inventories: {
|
||||
main: { b: 80, i: [{ n: "iron-plate", c: 100, r: 2 }, { s: 10, f: { name: "coal" } }] },
|
||||
armor: { i: {} },
|
||||
},
|
||||
},
|
||||
personal_logistic_slots: [],
|
||||
quick_bar: { 1: { n: "iron-plate" } },
|
||||
crafting_queue: { crafting_queue: [], ingredients: [] },
|
||||
recipe_notifications: "",
|
||||
components: { controller: true, inventories: true },
|
||||
});
|
||||
function accepts(data) {
|
||||
return validate({ playerName: "player", playerData: data });
|
||||
}
|
||||
|
||||
it("should accept exported and partial player data", function() {
|
||||
assert(accepts(exported), JSON.stringify(validate.errors));
|
||||
assert(accepts({ name: "player", generation: 0 }));
|
||||
assert(accepts(playerData("player", 0, { controller: "ghost", ticks_to_respawn: 300 })));
|
||||
assert(accepts(playerData("player", 0, { color: [1, 0, 0] })));
|
||||
});
|
||||
it("should reject data the scenario can't restore", function() {
|
||||
for (const [field, value] of [
|
||||
["controller", "not-a-controller"],
|
||||
["controller", "remote"],
|
||||
["color", "red"],
|
||||
["force", 1],
|
||||
["ticks_to_respawn", -1],
|
||||
["inventories", { main: { i: "iron-plate" } }],
|
||||
["inventories", { main: [] }],
|
||||
["character", { inventories: { main: { i: [{ s: 0 }] } } }],
|
||||
["shortcuts", { "toggle-personal-roboport": "yes" }],
|
||||
]) {
|
||||
assert(!accepts({ ...exported, [field]: value }), `accepted ${field} ${JSON.stringify(value)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
it("SetPlayerDataResponse should be round trip json serialisable", function() {
|
||||
testRoundTripJsonSerialisable(msg.SetPlayerDataResponse, testMatrix([1, 5]));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user