Skip to content

Commit cd2fbfd

Browse files
authored
fix: replace node-persist with minimal in-repo file storage (#1125)
* fix: replace node-persist with minimal in-repo file storage Implements the scope agreed in #1107: HAPFileStorage keeps node-persist 0.0.12's on-disk layout byte-for-byte and its four synchronous operations (initSync, getItem, setItemSync, removeItemSync); everything else from the removed node-persist API throws with a clear error. Drops the deprecated q dependency along with node-persist, mkdirp@0.5 and the node-persist build/type workarounds. Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p * fix: clearer error for non-string storage path values Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p * fix: address review feedback on HAPFileStorage Use an optional catch binding for the unused error variable and clarify the dir option documentation (absolute if provided, relative default). Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p * fix: make storage writes atomic and ignore directories on load Writes go to a temporary file which is renamed over the target, so an interrupted write can no longer truncate a file holding pairing data. Directories inside the storage directory are skipped when loading instead of aborting startup with EISDIR. Claude-Session: https://claude.ai/code/session_01DpHuagGzRQVzatVmNiWa7p * test: cover parent directory creation for nested keys Asserts the temporary file is written beside its target, which is what keeps the replacing rename atomic. * test: cover the default storage directory branch of HAPStorage Every existing HAPStorage test sets a custom storage path, and jest.setup.ts sets one on the singleton, so the branch which initialises into the default "persist" directory was no longer executed by any test. * fix: require storage keys to be a plain filename A key was read as a path, so "nested/key.json" created a subdirectory and "../escaped.json" wrote outside the storage directory. Since initSync now skips directories, a nested key wrote successfully and then read back as undefined after a restart, where node-persist 0.0.12 at least failed loudly with EISDIR. Rejecting such keys makes the documented one-file-per-key layout an invariant. Nothing in HAP-NodeJS uses a key containing a separator. * fix: close three more ways a stored value could be lost silently Keys starting with a dot are now rejected. initSync skips dotfiles, so `.hidden` was written, served from memory while the process ran, and gone after a restart: the same silent loss as a key naming a subdirectory, reached from a different direction. initSync now skips anything which is not a regular file, resolving symlinks. A symlink pointing at a directory does not report as one to readdir, so it reached readFileSync and aborted startup with the EISDIR that check exists to prevent. A link to a file still loads. The in-memory value is only replaced once the write reached the disk. A failed write left memory holding a value which was not saved, and since everything is served from memory that reads as an accessory which is paired now and unpaired after a restart.
1 parent 44f277e commit cd2fbfd

16 files changed

Lines changed: 619 additions & 134 deletions

.github/node-persist-ignore.js

Lines changed: 0 additions & 18 deletions
This file was deleted.

__mocks__/node-persist.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Config } from "jest";
33
const config: Config = {
44
"preset": "ts-jest",
55
"testEnvironment": "node",
6+
"setupFilesAfterEnv": ["<rootDir>/jest.setup.ts"],
67
"transform": {
78
"^.+\\.tsx?$": ["ts-jest", {
89
"tsconfig": "tsconfig.spec.json",

jest.setup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { HAPStorage } from "./src/lib/model/HAPStorage";
5+
6+
// Redirect the HAPStorage singleton into a fresh temporary directory for every test file,
7+
// so suites which exercise persistence (e.g. through Accessory.publish()) never touch
8+
// a real "persist" directory inside the repository.
9+
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "hap-nodejs-jest-"));
10+
HAPStorage.setCustomStoragePath(storageDir);
11+
12+
afterAll(() => {
13+
fs.rmSync(storageDir, { recursive: true, force: true });
14+
});

package-lock.json

Lines changed: 0 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"check": "npm install && npm outdated",
1515
"clean": "rimraf dist && rimraf coverage",
1616
"lint": "eslint 'src/**/*.{js,ts,json}'",
17-
"build": "rimraf dist && tsc && node .github/node-persist-ignore.js",
17+
"build": "rimraf dist && tsc",
1818
"generate-definitions": "ts-node src/lib/definitions/generate-definitions.ts",
1919
"prepublishOnly": "npm run build",
2020
"postpublish": "npm run clean",
@@ -58,7 +58,6 @@
5858
"debug": "^4.4.3",
5959
"fast-srp-hap": "^2.0.4",
6060
"futoin-hkdf": "^1.5.3",
61-
"node-persist": "^0.0.12",
6261
"source-map-support": "^0.5.21",
6362
"tslib": "^2.8.1",
6463
"tweetnacl": "^1.0.3"

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { readFileSync } from "node:fs";
77
* @group Utils
88
*/
99
export * as uuid from "./lib/util/uuid";
10+
export * from "./lib/model/HAPFileStorage";
1011
export * from "./lib/model/HAPStorage";
1112
export * from "./lib/Accessory";
1213
export * from "./lib/Bridge";

src/lib/model/AccessoryInfo.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { AssertionError } from "assert";
66
describe("AccessoryInfo", () => {
77
describe("#load()", () => {
88
it("should default category to Categories.OTHER when missing", () => {
9-
const mockStorage = HAPStorage.storage();
10-
(mockStorage.getItem as jest.Mock).mockReturnValueOnce({
9+
const username = "0E:AE:FC:45:7B:91";
10+
HAPStorage.storage().setItemSync(AccessoryInfo.persistKey(username), {
1111
displayName: "Test",
1212
pincode: "123-45-678",
1313
signSk: "aa".repeat(64),
@@ -16,7 +16,7 @@ describe("AccessoryInfo", () => {
1616
// category intentionally omitted
1717
});
1818

19-
const info = AccessoryInfo.load("0E:AE:FC:45:7B:91");
19+
const info = AccessoryInfo.load(username);
2020
expect(info).not.toBeNull();
2121
expect(info!.category).toBe(Categories.OTHER);
2222
expect(typeof info!.category).toBe("number");

src/lib/model/AccessoryInfo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export class AccessoryInfo {
230230
signPk: this.signPk.toString("hex"),
231231
pairedClients: {},
232232
// moving permissions into an extra object, so there is nothing to migrate from old files.
233-
// if the legacy node-persist storage should be upgraded some time, it would be reasonable to combine the storage
233+
// if the legacy storage format should be upgraded some time, it would be reasonable to combine the storage
234234
// of public keys (pairedClients object) and permissions.
235235
pairedClientsPermission: {},
236236
configVersion: this.configVersion,

0 commit comments

Comments
 (0)