From f529457f96c905135b2ff82ee770b9ab215d578a Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Mon, 27 Sep 2021 03:39:06 +0200 Subject: [PATCH] Replace ini packages with homebrew module The INI format Factorio use differs from what the ini package parse and produce. Most notably no escape mechanism exists in Factorio's parser which causes instance paths containing among other thing the # character to completely break. Fix by replacing the ini package with a Factorio compatible implementation of INI parsing and serialization. Resolves the problem in #414. --- packages/lib/factorio/export.js | 6 +-- packages/lib/factorio/server.js | 4 +- packages/lib/ini.js | 94 +++++++++++++++++++++++++++++++++ packages/lib/package.json | 1 - test/lib/ini.js | 93 ++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 packages/lib/ini.js create mode 100644 test/lib/ini.js diff --git a/packages/lib/factorio/export.js b/packages/lib/factorio/export.js index 2e71dd08..173f5cb3 100644 --- a/packages/lib/factorio/export.js +++ b/packages/lib/factorio/export.js @@ -2,10 +2,10 @@ "use strict"; const fs = require("fs-extra"); const path = require("path"); -const ini = require("ini"); const Jimp = require("jimp"); const JSZip = require("jszip"); +const libIni = require("../ini"); const libBuildMod = require("../build_mod"); /** @@ -312,7 +312,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { } let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg"); - mergeLocale(ini.parse(await fs.readFile(baseLocaleFilePath, "utf8"))); + mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8"))); for (let mod of modOrder) { if (["core", "base", "export"].includes(mod)) { @@ -330,7 +330,7 @@ async function exportLocale(server, modVersions, modOrder, languageCode) { } for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) { let content = await file.async("nodebuffer"); - mergeLocale(ini.parse(content.toString("utf8"))); + mergeLocale(libIni.parse(content.toString("utf8"))); } } diff --git a/packages/lib/factorio/server.js b/packages/lib/factorio/server.js index 5b08447a..ec2d4766 100644 --- a/packages/lib/factorio/server.js +++ b/packages/lib/factorio/server.js @@ -7,7 +7,7 @@ const events = require("events"); const util = require("util"); const crypto = require("crypto"); -const ini = require("ini"); +const libIni = require("../ini"); const rconClient = require("rcon-client"); const libErrors = require("../errors"); const { logger } = require("../logging"); @@ -1167,7 +1167,7 @@ class FactorioServer extends events.EventEmitter { } async _writeConfigIni() { - let content = ini.encode({ + let content = libIni.stringify({ path: { "read-data": this.dataPath(), "write-data": this.writePath(), diff --git a/packages/lib/ini.js b/packages/lib/ini.js new file mode 100644 index 00000000..413aeb92 --- /dev/null +++ b/packages/lib/ini.js @@ -0,0 +1,94 @@ +/** + * Factorio Compatible INI parser + * + * @module lib/ini + * @author Hornwitser + */ + +"use strict"; + +/** + * Parse INI file into object of sections + * @param {string} input - content of INI file to parse. + * @returns {object>} + * sections parsed from the ini file. + */ +function parse(input) { + const sections = {}; + let currentSection = sections; + const lines = input.split(/\r\n|\n/); + for (let number = 1; number <= lines.length; number++) { + let line = lines[number - 1].replace(/^[\t ]+/, ""); + if (!line || line[0] === "#" || line[0] === ";") { + continue; + } + + // Read section header + if (line[0] === "[") { + line = line.replace(/[\t ]+$/, ""); + if (line.slice(-1) !== "]") { + throw new Error(`Unterminated section header on line ${number}`); + } + const name = line.slice(1, -1); + if (Object.prototype.hasOwnProperty.call(sections, name)) { + throw new Error(`Duplicated section [${name}] on line ${number}`); + } + currentSection = {}; + sections[name] = currentSection; + continue; + } + + // Read key value + const equalIndex = line.indexOf("="); + if (equalIndex === -1) { + throw new Error(`Missing value for key ${line} on line ${number}`); + } + const key = line.slice(0, equalIndex); + if (Object.prototype.hasOwnProperty.call(currentSection, key)) { + throw new Error(`Duplicated key ${key} on line ${number}`); + } + const value = line.slice(equalIndex + 1); + currentSection[key] = value; + } + + return sections; +} + +/** + * Serialize an object of section definitions into an INI file + * @param {object>} sections - + * Sections to encode into ini format + * @returns {string} sections serialized into INI format + */ +function stringify(sections) { + let serialized = []; + let first = true; + + for (let [key, value] of Object.entries(sections)) { + if (typeof value === "string") { + serialized.push(`${key}=${value}`); + first = false; + } + } + + for (let [section, content] of Object.entries(sections)) { + if (typeof content === "object") { + if (!first) { + serialized.push(""); + } + first = false; + serialized.push(`[${section}]`); + for (let [key, value] of Object.entries(content)) { + serialized.push(`${key}=${value}`); + } + } + } + + if (serialized.length) { serialized.push(""); } + return serialized.join("\n"); +} + +module.exports = { + parse, + stringify, +}; diff --git a/packages/lib/package.json b/packages/lib/package.json index 63e9d6a3..f7b78901 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -15,7 +15,6 @@ "ajv": "^6.12.6", "chalk": "^4.1.0", "fs-extra": "^8.1.0", - "ini": "^1.3.5", "isomorphic-ws": "^4.0.1", "jimp": "^0.12.1", "jsonwebtoken": "^8.2.1", diff --git a/test/lib/ini.js b/test/lib/ini.js new file mode 100644 index 00000000..846e614c --- /dev/null +++ b/test/lib/ini.js @@ -0,0 +1,93 @@ +"use strict"; +const assert = require("assert").strict; +const libIni = require("@clusterio/lib/ini"); + +describe("lib/ini", function() { + describe("parse()", function() { + it("should ignore comments", function() { + assert.deepEqual( + libIni.parse("# A comment\n; Another comment\nfoo=bar\n"), + { foo: "bar" }, + ); + }); + it("should ignore certain whitespace", function() { + assert.deepEqual( + libIni.parse(" # A comment \n\n [spam] \t \n \tfoo=bar\n"), + { spam: { foo: "bar" }} + ); + }); + it("should preserve certain whitespace", function() { + assert.deepEqual( + libIni.parse("[\t spam\t ]\nfoo =\t bar \n"), + { "\t spam\t ": { "foo ": "\t bar " }} + ); + }); + it("should not treat characters specially", function() { + assert.deepEqual( + libIni.parse("[#;spam]\nfoo#;=#;bar=#;'\""), + { "#;spam": { "foo#;": "#;bar=#;'\"" }} + ); + }); + it("should handle windows line endings", function() { + assert.deepEqual( + libIni.parse("[spam]\r\nfoo=bar\r\n"), + { "spam": { "foo": "bar" }} + ); + }); + it("should throw on incomplete section", function() { + assert.throws( + () => libIni.parse("[spam"), + new Error("Unterminated section header on line 1") + ); + }); + it("should throw on missing value", function() { + assert.throws( + () => libIni.parse("key"), + new Error("Missing value for key key on line 1") + ); + }); + it("should throw on duplicated section", function() { + assert.throws( + () => libIni.parse("[spam]\n[spam]\n"), + new Error("Duplicated section [spam] on line 2") + ); + }); + it("should throw on duplicated keys", function() { + assert.throws( + () => libIni.parse("key=value\nkey=value\n"), + new Error("Duplicated key key on line 2") + ); + }); + }); + describe("stringify()", function() { + it("should serialize to reference output", function() { + let cases = [ + [{}, ""], + [{ foo: "bar" }, "foo=bar\n"], + [{ foo: { spam: "one", bar: "two" }}, "[foo]\nspam=one\nbar=two\n"], + [ + { start: "alpha", foo: { spam: "one", bar: "two" }, end: "beta" }, + "start=alpha\nend=beta\n\n[foo]\nspam=one\nbar=two\n", + ], + [{ "trail ": "\t tabs \t" }, "trail =\t tabs \t\n"], + [{ "#;spam": { "foo#;": "#;bar=#;'\"" }}, "[#;spam]\nfoo#;=#;bar=#;'\"\n"], + ]; + for (let [input, expected] of cases) { + assert.equal(libIni.stringify(input), expected); + } + }); + }); + it("should round trip object", function() { + for (let expected of [ + {}, + { simple: "value" }, + { section: { foo: "false", bar: "text" }}, + { mixed: "root", section: { foo: "1", bar: "2" }, end: "value" }, + { first: { foo: "1", bar: "2" }, second: { spam: "true" }}, + { '"#;[] ': ' "#;[] ', ' #;"[ ': { '"#;[] ': ' "#;[ ' }}, + ]) { + let actual = libIni.parse(libIni.stringify(expected)); + assert.deepEqual(actual, expected); + } + }); +});