diff --git a/.github/node-persist-ignore.js b/.github/node-persist-ignore.js deleted file mode 100644 index 86d73aa40..000000000 --- a/.github/node-persist-ignore.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * This script tried to solve the problem of having types collisions of node-persist types. - */ - -const path = require("path"); -const fs = require("fs"); - -const storageDefinition = "./dist/lib/model/HAPStorage.d.ts"; -const resolved = path.resolve(storageDefinition); - -if (!fs.existsSync(resolved)) { - throw new Error("Tried to update definition but could not find HAPStorage.d.ts!"); -} - -const rows = fs.readFileSync(resolved, "utf8").split("\n"); -rows.unshift("// @ts-ignore"); - -fs.writeFileSync(resolved, rows.join("\n")); diff --git a/__mocks__/node-persist.ts b/__mocks__/node-persist.ts deleted file mode 100644 index b5e9456d5..000000000 --- a/__mocks__/node-persist.ts +++ /dev/null @@ -1,10 +0,0 @@ -class Storage { - getItem = jest.fn(); - setItemSync = jest.fn(); - persistSync = jest.fn(); - removeItemSync = jest.fn(); - initSync = jest.fn(); - create = jest.fn().mockImplementation(() => new Storage()); -} - -export default new Storage(); diff --git a/jest.config.ts b/jest.config.ts index 3808686b1..a1ef23ea5 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,6 +3,7 @@ import type { Config } from "jest"; const config: Config = { "preset": "ts-jest", "testEnvironment": "node", + "setupFilesAfterEnv": ["/jest.setup.ts"], "transform": { "^.+\\.tsx?$": ["ts-jest", { "tsconfig": "tsconfig.spec.json", diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 000000000..6a3bcd3a9 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,14 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HAPStorage } from "./src/lib/model/HAPStorage"; + +// Redirect the HAPStorage singleton into a fresh temporary directory for every test file, +// so suites which exercise persistence (e.g. through Accessory.publish()) never touch +// a real "persist" directory inside the repository. +const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "hap-nodejs-jest-")); +HAPStorage.setCustomStoragePath(storageDir); + +afterAll(() => { + fs.rmSync(storageDir, { recursive: true, force: true }); +}); diff --git a/package-lock.json b/package-lock.json index a4b5bb884..b5f26c536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "debug": "^4.4.3", "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", - "node-persist": "^0.0.12", "source-map-support": "^0.5.21", "tslib": "^2.8.1", "tweetnacl": "^1.0.3" @@ -5075,18 +5074,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5149,16 +5136,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-persist": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-0.0.12.tgz", - "integrity": "sha512-Fbia3FYnURzaql53wLu0t19dmAwQg/tXT6O7YPmdwNwysNKEyFmgoT2BQlPD3XXQnYeiQVNvR5lfvufGwKuxhg==", - "license": "MIT", - "dependencies": { - "mkdirp": "~0.5.1", - "q": "~1.1.1" - } - }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -5573,17 +5550,6 @@ ], "license": "MIT" }, - "node_modules/q": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/q/-/q-1.1.2.tgz", - "integrity": "sha512-ROtylwux7Vkc4C07oKE/ReigUmb33kVoLtcR4SJ1QVqwaZkBEDL3vX4/kwFzIERQ5PfCl0XafbU8u2YUhyGgVA==", - "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", - "license": "MIT", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", diff --git a/package.json b/package.json index 9e2933f21..45cb4c7b6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check": "npm install && npm outdated", "clean": "rimraf dist && rimraf coverage", "lint": "eslint 'src/**/*.{js,ts,json}'", - "build": "rimraf dist && tsc && node .github/node-persist-ignore.js", + "build": "rimraf dist && tsc", "generate-definitions": "ts-node src/lib/definitions/generate-definitions.ts", "prepublishOnly": "npm run build", "postpublish": "npm run clean", @@ -58,7 +58,6 @@ "debug": "^4.4.3", "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", - "node-persist": "^0.0.12", "source-map-support": "^0.5.21", "tslib": "^2.8.1", "tweetnacl": "^1.0.3" diff --git a/src/index.ts b/src/index.ts index 52ef41506..6f34440c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { readFileSync } from "node:fs"; * @group Utils */ export * as uuid from "./lib/util/uuid"; +export * from "./lib/model/HAPFileStorage"; export * from "./lib/model/HAPStorage"; export * from "./lib/Accessory"; export * from "./lib/Bridge"; diff --git a/src/lib/model/AccessoryInfo.spec.ts b/src/lib/model/AccessoryInfo.spec.ts index 3bf4ddc57..df469e19d 100644 --- a/src/lib/model/AccessoryInfo.spec.ts +++ b/src/lib/model/AccessoryInfo.spec.ts @@ -6,8 +6,8 @@ import { AssertionError } from "assert"; describe("AccessoryInfo", () => { describe("#load()", () => { it("should default category to Categories.OTHER when missing", () => { - const mockStorage = HAPStorage.storage(); - (mockStorage.getItem as jest.Mock).mockReturnValueOnce({ + const username = "0E:AE:FC:45:7B:91"; + HAPStorage.storage().setItemSync(AccessoryInfo.persistKey(username), { displayName: "Test", pincode: "123-45-678", signSk: "aa".repeat(64), @@ -16,7 +16,7 @@ describe("AccessoryInfo", () => { // category intentionally omitted }); - const info = AccessoryInfo.load("0E:AE:FC:45:7B:91"); + const info = AccessoryInfo.load(username); expect(info).not.toBeNull(); expect(info!.category).toBe(Categories.OTHER); expect(typeof info!.category).toBe("number"); diff --git a/src/lib/model/AccessoryInfo.ts b/src/lib/model/AccessoryInfo.ts index 2ae2438f9..1031ef7df 100644 --- a/src/lib/model/AccessoryInfo.ts +++ b/src/lib/model/AccessoryInfo.ts @@ -230,7 +230,7 @@ export class AccessoryInfo { signPk: this.signPk.toString("hex"), pairedClients: {}, // moving permissions into an extra object, so there is nothing to migrate from old files. - // if the legacy node-persist storage should be upgraded some time, it would be reasonable to combine the storage + // if the legacy storage format should be upgraded some time, it would be reasonable to combine the storage // of public keys (pairedClients object) and permissions. pairedClientsPermission: {}, configVersion: this.configVersion, diff --git a/src/lib/model/HAPFileStorage.spec.ts b/src/lib/model/HAPFileStorage.spec.ts new file mode 100644 index 000000000..db1538e0c --- /dev/null +++ b/src/lib/model/HAPFileStorage.spec.ts @@ -0,0 +1,327 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HAPFileStorage } from "./HAPFileStorage"; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "hap-nodejs-storage-test-")); +} + +function symlinksPermitted(): boolean { + const probe = tempDir(); + try { + fs.symlinkSync(path.join(probe, "target"), path.join(probe, "link")); + return true; + } catch { + // creating a symlink on Windows needs Developer Mode or an elevated shell + return false; + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } +} + +const itWithSymlinks = symlinksPermitted() ? it : it.skip; + +describe(HAPFileStorage, () => { + let dir: string; + let storage: HAPFileStorage; + + beforeEach(() => { + dir = tempDir(); + storage = new HAPFileStorage(); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe("initSync", () => { + it("should create the storage directory recursively if it does not exist", () => { + const nested = path.join(dir, "does", "not", "exist"); + storage.initSync({ dir: nested }); + expect(fs.statSync(nested).isDirectory()).toBe(true); + }); + + it("should load existing files as raw-key filenames containing bare JSON values", () => { + // layout written by node-persist 0.0.12: filename is the raw key, content is JSON.stringify(value) + fs.writeFileSync(path.join(dir, "AccessoryInfo.CC223DE3CEF3.json"), "{\"displayName\":\"Outlet\",\"configVersion\":2}"); + storage.initSync({ dir: dir }); + expect(storage.getItem("AccessoryInfo.CC223DE3CEF3.json")).toEqual({ displayName: "Outlet", configVersion: 2 }); + }); + + it("should ignore dotfiles when loading the storage directory", () => { + fs.writeFileSync(path.join(dir, ".DS_Store"), "\"junk\""); + storage.initSync({ dir: dir }); + expect(storage.getItem(".DS_Store")).toBeUndefined(); + }); + + it("should ignore directories when loading the storage directory", () => { + // reading a directory as a file would abort startup with EISDIR + fs.mkdirSync(path.join(dir, "backup")); + fs.writeFileSync(path.join(dir, "key.json"), "1"); + + expect(() => storage.initSync({ dir: dir })).not.toThrow(); + expect(storage.getItem("backup")).toBeUndefined(); + expect(storage.getItem("key.json")).toEqual(1); + }); + + itWithSymlinks("should ignore a symlink pointing at a directory, which does not report as one", () => { + // a symlink is not a directory to readdir, so it would otherwise reach readFileSync and throw EISDIR + fs.mkdirSync(path.join(dir, "backup")); + fs.symlinkSync(path.join(dir, "backup"), path.join(dir, "backup-link")); + fs.writeFileSync(path.join(dir, "key.json"), "1"); + + expect(() => storage.initSync({ dir: dir })).not.toThrow(); + expect(storage.getItem("backup-link")).toBeUndefined(); + expect(storage.getItem("key.json")).toEqual(1); + }); + + itWithSymlinks("should still load a symlink pointing at a file", () => { + fs.writeFileSync(path.join(dir, "target.json"), "1"); + fs.symlinkSync(path.join(dir, "target.json"), path.join(dir, "link.json")); + + storage.initSync({ dir: dir }); + expect(storage.getItem("link.json")).toEqual(1); + }); + + itWithSymlinks("should ignore a symlink pointing at nothing rather than failing on it", () => { + fs.symlinkSync(path.join(dir, "missing.json"), path.join(dir, "dangling.json")); + fs.writeFileSync(path.join(dir, "key.json"), "1"); + + expect(() => storage.initSync({ dir: dir })).not.toThrow(); + expect(storage.getItem("dangling.json")).toBeUndefined(); + expect(storage.getItem("key.json")).toEqual(1); + }); + + it("should treat files with invalid JSON as undefined instead of throwing", () => { + fs.writeFileSync(path.join(dir, "corrupt.json"), "{ not json !"); + expect(() => storage.initSync({ dir: dir })).not.toThrow(); + expect(storage.getItem("corrupt.json")).toBeUndefined(); + }); + + it("should default to the relative directory 'persist' like node-persist 0.0.12", () => { + const previousCwd = process.cwd(); + try { + process.chdir(dir); + storage.initSync(); + storage.setItemSync("key.json", { foo: 1 }); + expect(fs.readFileSync(path.join(dir, "persist", "key.json"), "utf8")).toEqual("{\"foo\":1}"); + } finally { + process.chdir(previousCwd); + } + }); + + it("should reject relative custom directories", () => { + expect(() => storage.initSync({ dir: "relative/persist/path" })) + .toThrow(/absolute/); + }); + + it("should accept an absolute custom directory with a trailing separator", () => { + storage.initSync({ dir: dir + path.sep }); + storage.setItemSync("key.json", 1); + expect(fs.readFileSync(path.join(dir, "key.json"), "utf8")).toEqual("1"); + }); + + it("should reject a dir option which is not a string instead of silently using the default directory", () => { + expect(() => storage.initSync({ dir: undefined })).toThrow(/absolute/); + }); + + it("should reject unsupported node-persist init options instead of silently ignoring them", () => { + // @ts-expect-error: deliberately test an illegal option + expect(() => storage.initSync({ dir: dir, ttl: true })).toThrow(/ttl/); + }); + + it("should merge keys of a second directory on re-init and redirect writes to it, like node-persist 0.0.12", () => { + const secondDir = tempDir(); + try { + fs.writeFileSync(path.join(dir, "first.json"), "1"); + fs.writeFileSync(path.join(secondDir, "second.json"), "2"); + + storage.initSync({ dir: dir }); + storage.initSync({ dir: secondDir }); + + expect(storage.getItem("first.json")).toEqual(1); + expect(storage.getItem("second.json")).toEqual(2); + + storage.setItemSync("third.json", 3); + expect(fs.existsSync(path.join(secondDir, "third.json"))).toBe(true); + expect(fs.existsSync(path.join(dir, "third.json"))).toBe(false); + } finally { + fs.rmSync(secondDir, { recursive: true, force: true }); + } + }); + }); + + describe("setItemSync", () => { + beforeEach(() => { + storage.initSync({ dir: dir }); + }); + + it("should write the value as compact JSON without a trailing newline, byte-for-byte like node-persist 0.0.12", () => { + const value = { displayName: "Outlet", pairedClients: {}, configVersion: 2 }; + storage.setItemSync("AccessoryInfo.CC223DE3CEF3.json", value); + + const raw = fs.readFileSync(path.join(dir, "AccessoryInfo.CC223DE3CEF3.json")); + expect(raw.equals(Buffer.from(JSON.stringify(value), "utf8"))).toBe(true); + }); + + it("should use the raw key as the filename without hashing or escaping", () => { + storage.setItemSync("IdentifierCache.CC223DE3CEF3.json", {}); + expect(fs.readdirSync(dir)).toEqual(["IdentifierCache.CC223DE3CEF3.json"]); + }); + + it("should overwrite an existing value", () => { + storage.setItemSync("key.json", { a: 1 }); + storage.setItemSync("key.json", { b: 2 }); + expect(storage.getItem("key.json")).toEqual({ b: 2 }); + expect(fs.readFileSync(path.join(dir, "key.json"), "utf8")).toEqual("{\"b\":2}"); + }); + + it("should leave no temporary file behind after writing atomically", () => { + storage.setItemSync("key.json", { a: 1 }); + expect(fs.readdirSync(dir)).toEqual(["key.json"]); + }); + + it("should keep the previous contents intact when serializing the new value fails", () => { + storage.setItemSync("key.json", { a: 1 }); + const circular: Record = {}; + circular.self = circular; + + expect(() => storage.setItemSync("key.json", circular)).toThrow(); + expect(fs.readFileSync(path.join(dir, "key.json"), "utf8")).toEqual("{\"a\":1}"); + expect(fs.readdirSync(dir)).toEqual(["key.json"]); + }); + + it("should keep the previous in-memory value when the write fails, so memory matches the disk", () => { + storage.setItemSync("key.json", { a: 1 }); + const circular: Record = {}; + circular.self = circular; + + expect(() => storage.setItemSync("key.json", circular)).toThrow(); + // everything is served from memory, so a cached value which never reached the disk reads as saved until a restart + expect(storage.getItem("key.json")).toEqual({ a: 1 }); + }); + + it("should not remember a key whose only write failed", () => { + // renaming onto an existing directory fails, after the temporary file has been written + fs.mkdirSync(path.join(dir, "occupied")); + + expect(() => storage.setItemSync("occupied", { a: 1 })).toThrow(); + expect(storage.getItem("occupied")).toBeUndefined(); + }); + + it("should write the temporary file beside its target, as renaming across directories is not atomic", () => { + const renameSync = jest.spyOn(fs, "renameSync"); + try { + storage.setItemSync("key.json", { a: 1 }); + + expect(renameSync).toHaveBeenCalledTimes(1); + const [temporaryFile, file] = renameSync.mock.calls[0] as [string, string]; + expect(path.dirname(temporaryFile)).toEqual(path.dirname(file)); + } finally { + renameSync.mockRestore(); + } + }); + + it("should recreate a storage directory which was removed at runtime, like node-persist 0.0.12", () => { + // 0.0.12 did mkdirp.sync(path.dirname(file)) before every write + fs.rmSync(dir, { recursive: true, force: true }); + + storage.setItemSync("key.json", { a: 1 }); + expect(fs.readFileSync(path.join(dir, "key.json"), "utf8")).toEqual("{\"a\":1}"); + }); + + it.each([ + ["a key naming a subdirectory", "nested/key.json"], + ["a key containing a backslash", "nested\\key.json"], + ["a key starting with a dot", ".hidden"], + ["the parent directory", ".."], + ["the storage directory itself", "."], + ["an empty key", ""], + ])("should reject %s instead of writing where it could never be read back", (_, key) => { + // initSync skips both subdirectories and dotfiles, so such a value would silently come back undefined after a restart + expect(() => storage.setItemSync(key, { a: 1 })).toThrow(/plain filename/); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it("should reject a key escaping the storage directory rather than writing outside it", () => { + const inner = new HAPFileStorage(); + inner.initSync({ dir: path.join(dir, "inner") }); + + expect(() => inner.setItemSync(`..${path.sep}escaped.json`, { a: 1 })).toThrow(/plain filename/); + expect(fs.readdirSync(dir)).toEqual(["inner"]); + }); + + it("should clean up the temporary file when replacing the target fails", () => { + // renaming onto an existing directory fails, after the temporary file has been written + fs.mkdirSync(path.join(dir, "occupied")); + + expect(() => storage.setItemSync("occupied", { a: 1 })).toThrow(); + // the temporary file is cleaned up rather than left behind in the user's storage directory + expect(fs.readdirSync(dir)).toEqual(["occupied"]); + }); + }); + + describe("getItem", () => { + beforeEach(() => { + storage.initSync({ dir: dir }); + }); + + it("should return undefined for a missing key", () => { + expect(storage.getItem("missing.json")).toBeUndefined(); + }); + + it("should return the in-memory value without re-reading from disk, like node-persist 0.0.12", () => { + const value = { accessories: { uuid: [] } }; + storage.setItemSync("ControllerStorage.CC223DE3CEF3.json", value); + // ControllerStorage.load() mutates the returned object, relying on getItem returning the cached reference + expect(storage.getItem("ControllerStorage.CC223DE3CEF3.json")).toBe(value); + }); + + it("should fail loudly on node-persist's legacy callback form instead of never invoking the callback", () => { + storage.setItemSync("key.json", 1); + // @ts-expect-error: deliberately test the removed node-persist API + expect(() => storage.getItem("key.json", () => undefined)) + .toThrow(/callback.*initSync, getItem, setItemSync and removeItemSync/); + }); + }); + + describe("removeItemSync", () => { + beforeEach(() => { + storage.initSync({ dir: dir }); + }); + + it("should remove the file and the in-memory value", () => { + storage.setItemSync("key.json", { a: 1 }); + storage.removeItemSync("key.json"); + expect(storage.getItem("key.json")).toBeUndefined(); + expect(fs.existsSync(path.join(dir, "key.json"))).toBe(false); + }); + + it("should silently do nothing for a key which was never persisted", () => { + expect(() => storage.removeItemSync("missing.json")).not.toThrow(); + }); + + it("should reject a key which is not a plain filename rather than unlinking outside the storage directory", () => { + const inner = new HAPFileStorage(); + inner.initSync({ dir: path.join(dir, "inner") }); + fs.writeFileSync(path.join(dir, "victim.json"), "1"); + + expect(() => inner.removeItemSync(`..${path.sep}victim.json`)).toThrow(/plain filename/); + expect(fs.existsSync(path.join(dir, "victim.json"))).toBe(true); + }); + }); + + describe("unsupported node-persist methods", () => { + it.each([ + "init", "key", "keys", "length", "forEach", "values", "valuesWithKeyMatch", + "setItem", "getItemSync", "removeItem", "clear", "clearSync", + "persist", "persistSync", "stopInterval", + ])("should fail loudly when calling %s()", name => { + storage.initSync({ dir: dir }); + const method = (storage as unknown as Record unknown>)[name]; + expect(typeof method).toEqual("function"); + expect(() => method()).toThrow(new RegExp(`${name}.*initSync, getItem, setItemSync and removeItemSync`)); + }); + }); +}); diff --git a/src/lib/model/HAPFileStorage.ts b/src/lib/model/HAPFileStorage.ts new file mode 100644 index 000000000..814feb0e6 --- /dev/null +++ b/src/lib/model/HAPFileStorage.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Init options supported by {@link HAPFileStorage.initSync}. + * + * This is the subset of node-persist's init options which was ever functional for HAP-NodeJS's storage. + * Passing any other node-persist option throws instead of being silently ignored. + * + * @group Model + */ +export interface HAPFileStorageInitOptions { + /** + * Path of the storage directory. If provided, it must be an absolute path. + * If omitted, storage defaults to the directory `persist` inside the current working directory. + */ + dir?: string; +} + +const SUPPORTED_METHODS = "initSync, getItem, setItemSync and removeItemSync"; + +/** + * Every method of node-persist 0.0.12's `LocalStorage` prototype which {@link HAPFileStorage} does not implement. + * They are assigned as throwing stubs in the constructor, so code depending on the removed node-persist API + * fails with a clear error instead of exhibiting undefined behavior. + */ +const UNSUPPORTED_METHODS = [ + "setOptions", "init", "key", "keys", "length", "forEach", "values", "valuesWithKeyMatch", + "setItem", "getItemSync", "removeItem", "clear", "clearSync", "persist", "persistSync", + "persistKey", "persistKeySync", "removePersistedKey", "removePersistedKeySync", + "parseString", "parseTTLDir", "parseTTLDirSync", "parseDataDir", "parseDataDirSync", + "parseDir", "parseDirSync", "parseDataFile", "parseDataFileSync", "parseTTLFile", + "parseTTLFileSync", "parseFile", "parseFileSync", "isExpired", "resolveDir", + "stopInterval", "log", +] as const; + +/** + * Minimal synchronous file storage, a drop-in replacement for the subset of `node-persist@0.0.12` + * which HAP-NodeJS used: `initSync`, `getItem`, `setItemSync` and `removeItemSync`. + * + * The on-disk layout is kept byte-for-byte compatible with node-persist 0.0.12: + * one file per key inside the storage directory, named after the raw key (no hashing or escaping), + * containing the bare `JSON.stringify` representation of the value. Values are read once + * at {@link initSync} and served from memory afterwards. + * + * Keys must therefore be plain filenames: one which names a subdirectory, escapes the storage directory + * or starts with a dot is rejected rather than written somewhere it could never be read back from. + * + * Writes are atomic, which node-persist's were not: see {@link setItemSync}. + * + * @group Model + */ +export class HAPFileStorage { + + // node-persist 0.0.12 default: the relative directory "persist", resolved against the current working directory + private dir = "persist"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly data = new Map(); + + constructor() { + for (const method of UNSUPPORTED_METHODS) { + // runtime-only stubs, deliberately absent from the class type: TypeScript consumers get a compile error, + // JavaScript consumers get this error instead of "storage.keys is not a function" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this as any)[method] = () => { + throw new Error(`${method}() is not supported anymore: HAP-NodeJS replaced node-persist with a minimal file storage ` + + `which only implements ${SUPPORTED_METHODS} (see https://github.com/homebridge/HAP-NodeJS/issues/1107).`); + }; + } + } + + initSync(options?: HAPFileStorageInitOptions): void { + if (options) { + const unsupportedOptions = Object.keys(options).filter(key => key !== "dir"); + if (unsupportedOptions.length > 0) { + throw new Error(`HAP-NodeJS's storage does not support the init options: ${unsupportedOptions.join(", ")}. ` + + "Only \"dir\" is supported (see https://github.com/homebridge/HAP-NodeJS/issues/1107)."); + } + + if ("dir" in options) { + if (typeof options.dir !== "string" || !path.isAbsolute(options.dir)) { + // node-persist silently redirected relative paths into its own module directory inside node_modules, + // where the data was lost on the next npm install. There is no sane location to keep that behavior. + throw new Error(`HAP-NodeJS's storage requires an absolute storage path, got ${JSON.stringify(options.dir)}!`); + } + this.dir = path.normalize(options.dir); + } + } + + if (fs.existsSync(this.dir)) { + for (const entry of fs.readdirSync(this.dir, { withFileTypes: true })) { + if (entry.name.startsWith(".")) { + continue; + } + + const file = path.join(this.dir, entry.name); + // only regular files hold values: reading a directory here would abort startup with a cryptic EISDIR. + // this resolves symlinks, which entry.isDirectory() does not, so a link to a directory is skipped + // like the directory itself while a link to a file still loads + if (!HAPFileStorage.isFile(file)) { + continue; + } + + this.data.set(entry.name, HAPFileStorage.parse(fs.readFileSync(file, "utf8"))); + } + } else { + fs.mkdirSync(this.dir, { recursive: true }); + } + } + + /** + * Returns the in-memory value for the given key. + * node-persist's legacy `getItem(key, callback)` form throws, like every other removed API. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getItem(key: string, ...legacyCallback: never[]): any { + if (legacyCallback.length > 0) { + throw new Error("getItem() with a callback is not supported anymore: HAP-NodeJS replaced node-persist with a minimal file storage " + + `which only implements ${SUPPORTED_METHODS} (see https://github.com/homebridge/HAP-NodeJS/issues/1107).`); + } + + return this.data.get(key); + } + + /** + * Persists the given value, replacing the file for this key atomically. + * + * The value is written to a temporary file which is then renamed over the target, so an interrupted write + * leaves the previous contents intact instead of a truncated file. This matters because a truncated + * `AccessoryInfo` file reads back as an accessory which lost its pairings. + * + * The in-memory value is only replaced once the new one reached the disk, so a failed write cannot leave + * an accessory which reads as paired now and unpaired after a restart. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + setItemSync(key: string, value: any): void { + HAPFileStorage.assertPlainKey(key); + + // node-persist 0.0.12 ran mkdirp.sync before every write, which recreated a storage directory removed at runtime + fs.mkdirSync(this.dir, { recursive: true }); + + const file = path.join(this.dir, key); + // the dot prefix keeps a temporary file left behind by a crash from being loaded as a key by initSync, + // the pid keeps processes sharing a storage directory (like child bridges) from racing on the same temporary file. + // it sits in the storage directory next to its target, as renaming across directories would not be atomic + const temporaryFile = path.join(this.dir, `.${key}.${process.pid}.tmp`); + + try { + fs.writeFileSync(temporaryFile, JSON.stringify(value)); + fs.renameSync(temporaryFile, file); + } catch (error) { + try { + fs.unlinkSync(temporaryFile); + } catch { + // the temporary file may never have been created, in which case there is nothing to clean up + } + + throw error; + } + + // only now that the value is on disk: everything is served from memory afterwards, so a value cached + // for a write which never landed would be indistinguishable from a saved one until the next restart + this.data.set(key, value); + } + + removeItemSync(key: string): void { + HAPFileStorage.assertPlainKey(key); + + const file = path.join(this.dir, key); + if (fs.existsSync(file)) { + fs.unlinkSync(file); + } + + this.data.delete(key); + } + + /** + * Rejects a key which is not a plain filename, before it reaches the filesystem. + * + * `path.join` would otherwise read a key as a path: `nested/key.json` writes into a subdirectory which + * {@link initSync} skips, so the value comes back `undefined` after a restart, and `../escaped.json` writes + * outside the storage directory altogether. node-persist 0.0.12 created such subdirectories too, but crashed + * with `EISDIR` on the next load rather than losing the value quietly. + * + * A leading dot is rejected for the same reason: {@link initSync} skips dotfiles, so `.hidden` would be + * written, served from memory while the process runs, and then be gone after a restart. + */ + private static assertPlainKey(key: string): void { + if (!key || key.startsWith(".") || /[/\\]/.test(key)) { + throw new Error(`HAP-NodeJS's storage requires a key which is a plain filename, got ${JSON.stringify(key)}! ` + + "One file is kept per key inside the storage directory, so a key can neither name a subdirectory, escape it, " + + "nor start with a dot, which would be skipped the next time the directory is loaded."); + } + } + + /** + * Whether the given path is a regular file, following symlinks. + * + * An entry which cannot be stat'ed at all, like a broken or circular symlink, is one to skip + * rather than one to abort startup over. + */ + private static isFile(file: string): boolean { + try { + return fs.statSync(file).isFile(); + } catch { + return false; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static parse(json: string): any { + try { + return JSON.parse(json); + } catch { + // node-persist 0.0.12 swallowed unparseable files, leaving the value undefined + return undefined; + } + } + +} diff --git a/src/lib/model/HAPStorage.spec.ts b/src/lib/model/HAPStorage.spec.ts index 64be6c326..797c6bf4a 100644 --- a/src/lib/model/HAPStorage.spec.ts +++ b/src/lib/model/HAPStorage.spec.ts @@ -1,42 +1,69 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import nodePersist from "node-persist"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { HAPFileStorage } from "./HAPFileStorage"; import { HAPStorage } from "./HAPStorage"; describe(HAPStorage, () => { + let storagePath: string; + + beforeEach(() => { + storagePath = fs.mkdtempSync(path.join(os.tmpdir(), "hap-nodejs-storage-test-")); + }); + + afterEach(() => { + fs.rmSync(storagePath, { recursive: true, force: true }); + }); describe("storage", () => { it("should init storage correctly and only once", () => { const storage = new HAPStorage(); + storage.setCustomStoragePath(storagePath); // @ts-expect-error: private access expect(storage.localStore).toBeUndefined(); const localStore = storage.storage(); // init first time - expect(nodePersist.create).toHaveBeenCalledTimes(1); - expect(localStore.initSync).toHaveBeenCalledTimes(1); + expect(localStore).toBeInstanceOf(HAPFileStorage); // @ts-expect-error: private access expect(storage.localStore).toBeDefined(); - const localStore2 = storage.storage(); // init first time - expect(nodePersist.create).toHaveBeenCalledTimes(1); - expect(localStore2).toEqual(localStore); - expect(localStore2.initSync).toHaveBeenCalledTimes(1); + const localStore2 = storage.storage(); // must not init a second time + expect(localStore2).toBe(localStore); }); + it("should init into the default 'persist' directory when no custom path was set", () => { + const storage = new HAPStorage(); + + const previousCwd = process.cwd(); + try { + process.chdir(storagePath); + const localStore = storage.storage(); + + expect(localStore).toBeInstanceOf(HAPFileStorage); + expect(fs.statSync(path.join(storagePath, "persist")).isDirectory()).toBe(true); + } finally { + process.chdir(previousCwd); + } + }); }); describe("setCustomStoragePath", () => { it("should init storage correctly with custom storage path", () => { const storage = new HAPStorage(); - storage.setCustomStoragePath("asdfPath"); + const customPath = path.join(storagePath, "custom"); + storage.setCustomStoragePath(customPath); const localStore = storage.storage(); - expect(localStore.initSync).toHaveBeenCalledTimes(1); - expect(localStore.initSync).toHaveBeenLastCalledWith({ dir: "asdfPath" }); + + expect(fs.statSync(customPath).isDirectory()).toBe(true); + + localStore.setItemSync("key.json", { foo: 1 }); + expect(fs.readFileSync(path.join(customPath, "key.json"), "utf8")).toEqual("{\"foo\":1}"); }); it("should reject setCustomStoragePath after storage has already been initialized", () => { const storage = new HAPStorage(); + storage.setCustomStoragePath(storagePath); storage.storage(); expect(() => storage.setCustomStoragePath("customPath")).toThrow(Error); diff --git a/src/lib/model/HAPStorage.ts b/src/lib/model/HAPStorage.ts index 45c066fc0..79a23ae47 100644 --- a/src/lib/model/HAPStorage.ts +++ b/src/lib/model/HAPStorage.ts @@ -1,6 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import storage, { LocalStorage } from "node-persist"; +import { HAPFileStorage } from "./HAPFileStorage"; /** * @group Model @@ -9,10 +7,10 @@ export class HAPStorage { private static readonly INSTANCE = new HAPStorage(); - private localStore?: LocalStorage; + private localStore?: HAPFileStorage; private customStoragePath?: string; - public static storage(): LocalStorage { + public static storage(): HAPFileStorage { return this.INSTANCE.storage(); } @@ -20,9 +18,9 @@ export class HAPStorage { this.INSTANCE.setCustomStoragePath(path); } - public storage(): LocalStorage { + public storage(): HAPFileStorage { if (!this.localStore) { - this.localStore = storage.create(); + this.localStore = new HAPFileStorage(); if (this.customStoragePath) { this.localStore.initSync({ diff --git a/src/lib/model/IdentifierCache.spec.ts b/src/lib/model/IdentifierCache.spec.ts index 5e8c7c158..a66fa7e92 100644 --- a/src/lib/model/IdentifierCache.spec.ts +++ b/src/lib/model/IdentifierCache.spec.ts @@ -1,14 +1,6 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { LocalStorage } from "node-persist"; import { IdentifierCache } from "./IdentifierCache"; import { HAPStorage } from "./HAPStorage"; -function pullOutLocalStore(): LocalStorage { - // @ts-expect-error: private access - return HAPStorage.INSTANCE.localStore; -} - const createIdentifierCache = (username = "username") => { return new IdentifierCache(username); }; @@ -102,18 +94,23 @@ describe("IdentifierCache", () => { describe("#save()", () => { it("persists the cache to file storage", () => { const identifierCache = createIdentifierCache(); + identifierCache.setCache("foo", 1); identifierCache.save(); - expect(pullOutLocalStore().setItemSync).toHaveBeenCalledTimes(1); + const saved = HAPStorage.storage().getItem(IdentifierCache.persistKey(identifierCache.username)); + expect(saved).toEqual({ cache: { foo: 1 } }); }); }); describe("#remove()", () => { it("removes the cache from file storage", () => { const identifierCache = createIdentifierCache(); + identifierCache.save(); + expect(HAPStorage.storage().getItem(IdentifierCache.persistKey(identifierCache.username))).toBeDefined(); + IdentifierCache.remove(identifierCache.username); - expect(pullOutLocalStore().removeItemSync).toHaveBeenCalledTimes(1); + expect(HAPStorage.storage().getItem(IdentifierCache.persistKey(identifierCache.username))).toBeUndefined(); }); }); diff --git a/src/types/node-persist.d.ts b/src/types/node-persist.d.ts deleted file mode 100644 index 406accc32..000000000 --- a/src/types/node-persist.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -declare module "node-persist" { - - export interface InitOptions { - dir?: string; // default 'persist' - stringify?: typeof JSON.stringify, // default JSON.stringify - parse?: typeof JSON.parse, // default JSON.parse - encoding?: string, // default 'utf8' - logging?: boolean, - continuous?: boolean, // default true (instantly persists to disk) - interval?: false | number, // milliseconds - ttl?: false | true | number, // can be true for 24h default or a number in MILLISECONDS - } - - export class LocalStorage { - - constructor(options?: InitOptions); - - initSync(options?: InitOptions): void; - getItem(key: string): any; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - setItemSync(key: string, value: any): void; - removeItemSync(key: string): void - persistSync(): void; - - } - - export function initSync(options?: InitOptions): void; - export function create(options?: InitOptions): LocalStorage; - export function getItem(key: string): any; - export function setItemSync(key: string, data: any): void; - export function persistSync(): void; - export function removeItemSync(key: string): void; - -} diff --git a/typedoc.config.mjs b/typedoc.config.mjs index 3d896e592..9703025bf 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -9,7 +9,6 @@ const config = { "src/index.ts", ], "intentionallyNotExported": [ - "LocalStorage", "MulticastOptions", ], "excludePrivate": true,