From 48408a906bd84a4ec85fd1076e15d36021138fc2 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 2 Dec 2025 21:13:59 +0100 Subject: [PATCH 1/3] test: move makeStorageKey tests to unit file, fix test title - Rename misleading "should handle lookup consistently within tolerance window" to "should return lock info for active lock" - Move Storage Key Consistency tests from E2E to new crypto.test.ts - Add comprehensive unit tests for generateLockId, hashKey, formatFence --- test/unit/crypto.test.ts | 172 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 test/unit/crypto.test.ts diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts new file mode 100644 index 0000000..73a30d2 --- /dev/null +++ b/test/unit/crypto.test.ts @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2025-present Kriasoft +// SPDX-License-Identifier: MIT + +/** + * Unit tests for crypto utilities (makeStorageKey, generateLockId, etc.) + */ + +import { describe, expect, it } from "bun:test"; +import { + formatFence, + generateLockId, + hashKey, + makeStorageKey, +} from "../../common/crypto.js"; + +describe("makeStorageKey", () => { + it("should preserve user key when no truncation needed", () => { + const userKey = "resource:payment:12345"; + + // Redis-style (prefix with reserve for derived keys) + const redisReserve = 25; // "id:" + 22-char lockId + const redisKey = makeStorageKey("test", userKey, 1000, redisReserve); + + // Firestore-style (no prefix, no reserve) + const firestoreKey = makeStorageKey("", userKey, 1500, 0); + + expect(redisKey).toBe("test:resource:payment:12345"); + expect(firestoreKey).toBe(userKey); + }); + + it("should hash keys that exceed backend limits", () => { + const longKey = "x".repeat(2000); + const redisReserve = 25; + + const redisKey = makeStorageKey("test", longKey, 1000, redisReserve); + const firestoreKey = makeStorageKey("", longKey, 1500, 0); + + // When truncation occurs, both should use hashed form (base64url) + expect(redisKey.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(firestoreKey.length).toBeLessThanOrEqual(1500); + // Hashed keys don't contain "xxxx" patterns - they use base64url chars + expect(redisKey).toMatch(/^test:[A-Za-z0-9_-]+$/); + expect(firestoreKey).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("should derive fence keys from base storage key (two-step pattern)", () => { + const userKey = "resource:critical:operation"; + + // Redis: two-step derivation + const redisReserve = 25; + const redisBaseKey = makeStorageKey("test", userKey, 1000, redisReserve); + const redisFenceKey = makeStorageKey( + "test", + `fence:${redisBaseKey}`, + 1000, + redisReserve, + ); + + // Firestore: two-step derivation + const firestoreBaseKey = makeStorageKey("", userKey, 1500, 0); + const firestoreFenceDocId = makeStorageKey( + "", + `fence:${firestoreBaseKey}`, + 1500, + 0, + ); + + // Verify both backends use two-step pattern + expect(redisFenceKey).toContain("fence:"); + expect(firestoreFenceDocId).toContain("fence:"); + }); + + it("should ensure 1:1 mapping for long keys with hash truncation", () => { + const longKey = "x".repeat(2000); + const redisReserve = 25; + + const redisBaseLong = makeStorageKey("test", longKey, 1000, redisReserve); + const redisFenceLong = makeStorageKey( + "test", + `fence:${redisBaseLong}`, + 1000, + redisReserve, + ); + const firestoreBaseLong = makeStorageKey("", longKey, 1500, 0); + const firestoreFenceLong = makeStorageKey( + "", + `fence:${firestoreBaseLong}`, + 1500, + 0, + ); + + // Both backends ensure keys stay within limits + expect(redisBaseLong.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(redisFenceLong.length).toBeLessThanOrEqual(1000 - redisReserve); + expect(firestoreBaseLong.length).toBeLessThanOrEqual(1500); + expect(firestoreFenceLong.length).toBeLessThanOrEqual(1500); + }); + + it("should strip trailing colons from prefix", () => { + const key = "resource"; + + // Both should produce the same result + const withColon = makeStorageKey("prefix:", key, 1000, 0); + const withoutColon = makeStorageKey("prefix", key, 1000, 0); + + expect(withColon).toBe("prefix:resource"); + expect(withoutColon).toBe("prefix:resource"); + }); + + it("should throw on empty key", () => { + expect(() => makeStorageKey("test", "", 1000, 0)).toThrow( + "Key must not be empty", + ); + }); + + it("should throw when prefix exceeds backend limit", () => { + const longPrefix = "x".repeat(500); + expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow( + "Prefix exceeds backend limit", + ); + }); +}); + +describe("generateLockId", () => { + it("should generate 22-character base64url lock IDs", () => { + const lockId = generateLockId(); + expect(lockId).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it("should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateLockId()); + } + expect(ids.size).toBe(100); + }); +}); + +describe("hashKey", () => { + it("should produce 24-character hex hash", () => { + const hash = hashKey("test-key"); + expect(hash).toMatch(/^[0-9a-f]{24}$/); + }); + + it("should produce deterministic output", () => { + const hash1 = hashKey("same-key"); + const hash2 = hashKey("same-key"); + expect(hash1).toBe(hash2); + }); + + it("should produce different hashes for different keys", () => { + const hash1 = hashKey("key-1"); + const hash2 = hashKey("key-2"); + expect(hash1).not.toBe(hash2); + }); +}); + +describe("formatFence", () => { + it("should format fence as 15-digit zero-padded string", () => { + expect(formatFence(1)).toBe("000000000000001"); + expect(formatFence(123456)).toBe("000000000123456"); + expect(formatFence(999999999999999n)).toBe("999999999999999"); + }); + + it("should throw on negative values", () => { + expect(() => formatFence(-1)).toThrow("non-negative"); + }); + + it("should throw on values exceeding 15 digits", () => { + expect(() => formatFence(1000000000000000n)).toThrow("exceeds 15-digit"); + }); +}); From 4d23686f2cc20426f530a6cf47603db3f662ca47 Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 2 Dec 2025 21:18:48 +0100 Subject: [PATCH 2/3] test: consolidate crypto tests into common/crypto.test.ts --- test/unit/crypto.test.ts | 172 --------------------------------------- 1 file changed, 172 deletions(-) delete mode 100644 test/unit/crypto.test.ts diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts deleted file mode 100644 index 73a30d2..0000000 --- a/test/unit/crypto.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -// SPDX-FileCopyrightText: 2025-present Kriasoft -// SPDX-License-Identifier: MIT - -/** - * Unit tests for crypto utilities (makeStorageKey, generateLockId, etc.) - */ - -import { describe, expect, it } from "bun:test"; -import { - formatFence, - generateLockId, - hashKey, - makeStorageKey, -} from "../../common/crypto.js"; - -describe("makeStorageKey", () => { - it("should preserve user key when no truncation needed", () => { - const userKey = "resource:payment:12345"; - - // Redis-style (prefix with reserve for derived keys) - const redisReserve = 25; // "id:" + 22-char lockId - const redisKey = makeStorageKey("test", userKey, 1000, redisReserve); - - // Firestore-style (no prefix, no reserve) - const firestoreKey = makeStorageKey("", userKey, 1500, 0); - - expect(redisKey).toBe("test:resource:payment:12345"); - expect(firestoreKey).toBe(userKey); - }); - - it("should hash keys that exceed backend limits", () => { - const longKey = "x".repeat(2000); - const redisReserve = 25; - - const redisKey = makeStorageKey("test", longKey, 1000, redisReserve); - const firestoreKey = makeStorageKey("", longKey, 1500, 0); - - // When truncation occurs, both should use hashed form (base64url) - expect(redisKey.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(firestoreKey.length).toBeLessThanOrEqual(1500); - // Hashed keys don't contain "xxxx" patterns - they use base64url chars - expect(redisKey).toMatch(/^test:[A-Za-z0-9_-]+$/); - expect(firestoreKey).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("should derive fence keys from base storage key (two-step pattern)", () => { - const userKey = "resource:critical:operation"; - - // Redis: two-step derivation - const redisReserve = 25; - const redisBaseKey = makeStorageKey("test", userKey, 1000, redisReserve); - const redisFenceKey = makeStorageKey( - "test", - `fence:${redisBaseKey}`, - 1000, - redisReserve, - ); - - // Firestore: two-step derivation - const firestoreBaseKey = makeStorageKey("", userKey, 1500, 0); - const firestoreFenceDocId = makeStorageKey( - "", - `fence:${firestoreBaseKey}`, - 1500, - 0, - ); - - // Verify both backends use two-step pattern - expect(redisFenceKey).toContain("fence:"); - expect(firestoreFenceDocId).toContain("fence:"); - }); - - it("should ensure 1:1 mapping for long keys with hash truncation", () => { - const longKey = "x".repeat(2000); - const redisReserve = 25; - - const redisBaseLong = makeStorageKey("test", longKey, 1000, redisReserve); - const redisFenceLong = makeStorageKey( - "test", - `fence:${redisBaseLong}`, - 1000, - redisReserve, - ); - const firestoreBaseLong = makeStorageKey("", longKey, 1500, 0); - const firestoreFenceLong = makeStorageKey( - "", - `fence:${firestoreBaseLong}`, - 1500, - 0, - ); - - // Both backends ensure keys stay within limits - expect(redisBaseLong.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(redisFenceLong.length).toBeLessThanOrEqual(1000 - redisReserve); - expect(firestoreBaseLong.length).toBeLessThanOrEqual(1500); - expect(firestoreFenceLong.length).toBeLessThanOrEqual(1500); - }); - - it("should strip trailing colons from prefix", () => { - const key = "resource"; - - // Both should produce the same result - const withColon = makeStorageKey("prefix:", key, 1000, 0); - const withoutColon = makeStorageKey("prefix", key, 1000, 0); - - expect(withColon).toBe("prefix:resource"); - expect(withoutColon).toBe("prefix:resource"); - }); - - it("should throw on empty key", () => { - expect(() => makeStorageKey("test", "", 1000, 0)).toThrow( - "Key must not be empty", - ); - }); - - it("should throw when prefix exceeds backend limit", () => { - const longPrefix = "x".repeat(500); - expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow( - "Prefix exceeds backend limit", - ); - }); -}); - -describe("generateLockId", () => { - it("should generate 22-character base64url lock IDs", () => { - const lockId = generateLockId(); - expect(lockId).toMatch(/^[A-Za-z0-9_-]{22}$/); - }); - - it("should generate unique IDs", () => { - const ids = new Set(); - for (let i = 0; i < 100; i++) { - ids.add(generateLockId()); - } - expect(ids.size).toBe(100); - }); -}); - -describe("hashKey", () => { - it("should produce 24-character hex hash", () => { - const hash = hashKey("test-key"); - expect(hash).toMatch(/^[0-9a-f]{24}$/); - }); - - it("should produce deterministic output", () => { - const hash1 = hashKey("same-key"); - const hash2 = hashKey("same-key"); - expect(hash1).toBe(hash2); - }); - - it("should produce different hashes for different keys", () => { - const hash1 = hashKey("key-1"); - const hash2 = hashKey("key-2"); - expect(hash1).not.toBe(hash2); - }); -}); - -describe("formatFence", () => { - it("should format fence as 15-digit zero-padded string", () => { - expect(formatFence(1)).toBe("000000000000001"); - expect(formatFence(123456)).toBe("000000000123456"); - expect(formatFence(999999999999999n)).toBe("999999999999999"); - }); - - it("should throw on negative values", () => { - expect(() => formatFence(-1)).toThrow("non-negative"); - }); - - it("should throw on values exceeding 15 digits", () => { - expect(() => formatFence(1000000000000000n)).toThrow("exceeds 15-digit"); - }); -}); From 47e4cb42f5e6682f87aa9f9c697c94eec9f9b54a Mon Sep 17 00:00:00 2001 From: Konstantin Tarkus Date: Tue, 9 Dec 2025 11:46:43 +0100 Subject: [PATCH 3/3] fix: harden formatFence and makeStorageKey input validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatFence: reject non-finite, non-integer, and negative values before BigInt conversion (prevents leaked RangeError, fixes -0.1→0 bug) - makeStorageKey: validate backendLimitBytes (positive int) and reserveBytes (non-negative int) to prevent limit bypass - Add FENCE_FORMAT_MAX constant (10^15-1) distinct from operational FENCE_THRESHOLDS.MAX (9e14) - Update tests for stricter contracts - Bump version to 2.5.3 --- .vscode/settings.json | 2 +- README.md | 30 ++++++++++++++++-- bun.lock | 14 ++++----- common/constants.ts | 7 +++++ common/crypto.ts | 51 +++++++++++++++++++++++++------ package.json | 8 ++--- test/unit/common/crypto.test.ts | 54 ++++++++++++++++++++++++++++++--- 7 files changed, 136 insertions(+), 30 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 905d09c..225627a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -35,5 +35,5 @@ // File explorer improvements "explorer.sortOrder": "type", - "cSpell.words": ["firestore", "kriasoft", "syncguard", "vitepress"] + "cSpell.words": ["csprng", "firestore", "kriasoft", "syncguard", "vitepress"] } diff --git a/README.md b/README.md index f5b584f..4d2c9d2 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,25 @@ TypeScript distributed lock library that prevents race conditions across services. Supports Redis, PostgreSQL, and Firestore backends with automatic cleanup, fencing tokens, and bulletproof concurrency control. +## Documentation + +- **Docs site:** https://kriasoft.com/syncguard/ +- **Backend guides:** [Redis](./redis/README.md) · [PostgreSQL](./postgres/README.md) · [Firestore](./firestore/README.md) + ## Requirements - **Node.js** ≥20.0.0 (targets AsyncDisposable/`await using`; older runtimes require try/finally plus a polyfill, but official support is 20+) +## Compatibility + +| Runtime / Backend | Support | +| ------------------ | --------------------------------------------------------------------------- | +| Node.js | 20+ (native AsyncDisposable/`await using`) | +| Bun | 1.0+ (used for `bun test`) | +| Redis backend | Redis 6+ with `ioredis` ^5 peer dependency | +| PostgreSQL backend | PostgreSQL 12+ with `postgres` ^3 peer dependency | +| Firestore backend | `@google-cloud/firestore` ^8 peer dependency (emulator supported for tests) | + ## Installation SyncGuard is backend-agnostic. Install the base package plus any backends you need: @@ -20,9 +35,9 @@ SyncGuard is backend-agnostic. Install the base package plus any backends you ne npm install syncguard # Choose one or more backends (optional peer dependencies): -npm install ioredis # for Redis backend -npm install postgres # for PostgreSQL backend -npm install @google-cloud/firestore # for Firestore backend +npm install syncguard ioredis # Redis backend +npm install syncguard postgres # PostgreSQL backend +npm install syncguard @google-cloud/firestore # Firestore backend ``` Only install the backend packages you actually use. If you attempt to use a backend without its package installed, you'll get a clear error message. @@ -92,6 +107,8 @@ await lock( ### Manual Lock Control with Automatic Cleanup +Node.js 20+ supports `await using` natively; for older runtimes, drop to try/finally (see below). + Use `await using` for automatic cleanup on all code paths (Node.js ≥20): ```typescript @@ -572,6 +589,13 @@ acquisition: { } ``` +## Development + +- `bun test test/unit` — fast unit tests +- `bun test test/contracts test/e2e` — contracts + e2e suite +- `npm run build` — type-check and emit `dist/` +- `npm run redis` / `npm run firestore` — spin up local Redis or Firestore emulator for tests + ## Contributing We welcome contributions! Here's how you can help: diff --git a/bun.lock b/bun.lock index 6d9ab70..b225a7c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,14 +6,14 @@ "name": "syncguard", "devDependencies": { "@google-cloud/firestore": "^8.0.0", - "@types/bun": "1.3.3", - "firebase-tools": "^14.26.0", + "@types/bun": "1.3.4", + "firebase-tools": "^14.27.0", "gh-pages": "^6.3.0", "husky": "^9.1.7", "ioredis": "^5.8.1", "lint-staged": "^16.2.7", "postgres": "^3.4.7", - "prettier": "^3.7.3", + "prettier": "^3.7.4", "prettier-plugin-sql": "^0.19.2", "typescript": "^5.9.3", "vitepress": "^2.0.0-alpha.15", @@ -309,7 +309,7 @@ "@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -461,7 +461,7 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -731,7 +731,7 @@ "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "firebase-tools": ["firebase-tools@14.26.0", "", { "dependencies": { "@apphosting/build": "^0.1.6", "@apphosting/common": "^0.0.8", "@electric-sql/pglite": "^0.3.3", "@electric-sql/pglite-tools": "^0.2.8", "@google-cloud/cloud-sql-connector": "^1.3.3", "@google-cloud/pubsub": "^5.2.0", "@inquirer/prompts": "^7.4.0", "@modelcontextprotocol/sdk": "^1.10.2", "abort-controller": "^3.0.0", "ajv": "^8.17.1", "ajv-formats": "3.0.1", "archiver": "^7.0.0", "async-lock": "1.4.1", "body-parser": "^1.19.0", "chokidar": "^3.6.0", "cjson": "^0.3.1", "cli-table3": "0.6.5", "colorette": "^2.0.19", "commander": "^5.1.0", "configstore": "^5.0.1", "cors": "^2.8.5", "cross-env": "^7.0.3", "cross-spawn": "^7.0.5", "csv-parse": "^5.0.4", "deep-equal-in-any-order": "^2.0.6", "exegesis": "^4.2.0", "exegesis-express": "^4.0.0", "express": "^4.16.4", "filesize": "^6.1.0", "form-data": "^4.0.1", "fs-extra": "^10.1.0", "fuzzy": "^0.1.3", "gaxios": "^6.7.0", "glob": "^10.4.1", "google-auth-library": "^9.11.0", "ignore": "^7.0.4", "js-yaml": "^3.14.1", "jsonwebtoken": "^9.0.0", "leven": "^3.1.0", "libsodium-wrappers": "^0.7.10", "lodash": "^4.17.21", "lsofi": "1.0.0", "marked": "^13.0.2", "marked-terminal": "^7.0.0", "mime": "^2.5.2", "minimatch": "^3.0.4", "morgan": "^1.10.0", "node-fetch": "^2.6.7", "open": "^6.3.0", "ora": "^5.4.1", "p-limit": "^3.0.1", "pg": "^8.11.3", "pg-gateway": "^0.3.0-beta.4", "pglite-2": "npm:@electric-sql/pglite@0.2.17", "portfinder": "^1.0.32", "progress": "^2.0.3", "proxy-agent": "^6.3.0", "retry": "^0.13.1", "semver": "^7.5.2", "sql-formatter": "^15.3.0", "stream-chain": "^2.2.4", "stream-json": "^1.7.3", "superstatic": "^10.0.0", "tar": "^6.1.11", "tcp-port-used": "^1.0.2", "tmp": "^0.2.3", "triple-beam": "^1.3.0", "universal-analytics": "^0.5.3", "update-notifier-cjs": "^5.1.6", "uuid": "^8.3.2", "winston": "^3.0.0", "winston-transport": "^4.4.0", "ws": "^7.5.10", "yaml": "^2.4.1", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" }, "bin": { "firebase": "lib/bin/firebase.js" } }, "sha512-pjVNDvmY4s3HkBe0iI5mum4hGpNzVwrf0J68KP51XRvhU/tYDXzMr6N+FBjdJtkJM6I7wx1KqgHsqvJurCun/A=="], + "firebase-tools": ["firebase-tools@14.27.0", "", { "dependencies": { "@apphosting/build": "^0.1.6", "@apphosting/common": "^0.0.8", "@electric-sql/pglite": "^0.3.3", "@electric-sql/pglite-tools": "^0.2.8", "@google-cloud/cloud-sql-connector": "^1.3.3", "@google-cloud/pubsub": "^5.2.0", "@inquirer/prompts": "^7.4.0", "@modelcontextprotocol/sdk": "^1.10.2", "abort-controller": "^3.0.0", "ajv": "^8.17.1", "ajv-formats": "3.0.1", "archiver": "^7.0.0", "async-lock": "1.4.1", "body-parser": "^1.19.0", "chokidar": "^3.6.0", "cjson": "^0.3.1", "cli-table3": "0.6.5", "colorette": "^2.0.19", "commander": "^5.1.0", "configstore": "^5.0.1", "cors": "^2.8.5", "cross-env": "^7.0.3", "cross-spawn": "^7.0.5", "csv-parse": "^5.0.4", "deep-equal-in-any-order": "^2.0.6", "exegesis": "^4.2.0", "exegesis-express": "^4.0.0", "express": "^4.16.4", "filesize": "^6.1.0", "form-data": "^4.0.1", "fs-extra": "^10.1.0", "fuzzy": "^0.1.3", "gaxios": "^6.7.0", "glob": "^10.4.1", "google-auth-library": "^9.11.0", "ignore": "^7.0.4", "js-yaml": "^3.14.1", "jsonwebtoken": "^9.0.0", "leven": "^3.1.0", "libsodium-wrappers": "^0.7.10", "lodash": "^4.17.21", "lsofi": "1.0.0", "marked": "^13.0.2", "marked-terminal": "^7.0.0", "mime": "^2.5.2", "minimatch": "^3.0.4", "morgan": "^1.10.0", "node-fetch": "^2.6.7", "open": "^6.3.0", "ora": "^5.4.1", "p-limit": "^3.0.1", "pg": "^8.11.3", "pg-gateway": "^0.3.0-beta.4", "pglite-2": "npm:@electric-sql/pglite@0.2.17", "portfinder": "^1.0.32", "progress": "^2.0.3", "proxy-agent": "^6.3.0", "retry": "^0.13.1", "semver": "^7.5.2", "sql-formatter": "^15.3.0", "stream-chain": "^2.2.4", "stream-json": "^1.7.3", "superstatic": "^10.0.0", "tar": "^6.1.11", "tcp-port-used": "^1.0.2", "tmp": "^0.2.3", "triple-beam": "^1.3.0", "universal-analytics": "^0.5.3", "update-notifier-cjs": "^5.1.6", "uuid": "^8.3.2", "winston": "^3.0.0", "winston-transport": "^4.4.0", "ws": "^7.5.10", "yaml": "^2.4.1", "zod": "^3.24.3", "zod-to-json-schema": "^3.24.5" }, "bin": { "firebase": "lib/bin/firebase.js" } }, "sha512-HrucHJ69mLM9pQhZFO1rb0N/QMpZD4iznoOtKd2lctEELPtbSMN5JHgdgzLlf+EXn5aQy87u5zlPd/0xwwyYTQ=="], "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], @@ -1221,7 +1221,7 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prettier": ["prettier@3.7.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg=="], + "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], "prettier-plugin-sql": ["prettier-plugin-sql@0.19.2", "", { "dependencies": { "jsox": "^1.2.123", "node-sql-parser": "^5.3.10", "sql-formatter": "^15.6.5", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-DAu1Jcanpvs32OAOXsqaVXOpPs4nFLVkB3XwzRiZZVNL5/c+XdlNxWFMiMpMhYhmCG5BW3srK8mhikCOv5tPfg=="], diff --git a/common/constants.ts b/common/constants.ts index e1ede56..5e5ba88 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -153,3 +153,10 @@ export const FENCE_THRESHOLDS = { */ WARN: "090000000000000", } as const; + +/** + * Maximum value that can be formatted as a 15-digit fence token. + * This is the format limit (10^15 - 1), distinct from FENCE_THRESHOLDS.MAX + * which is the operational limit enforced by backends. + */ +export const FENCE_FORMAT_MAX = 999_999_999_999_999n; diff --git a/common/crypto.ts b/common/crypto.ts index 3f5dff6..520ff0c 100644 --- a/common/crypto.ts +++ b/common/crypto.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { createHash, randomBytes } from "node:crypto"; +import { FENCE_FORMAT_MAX } from "./constants.js"; import { LockError } from "./errors.js"; import type { HashId } from "./types.js"; @@ -35,12 +36,11 @@ export function generateLockId(): string { /** * Canonical 96-bit hash for user keys (NFC normalized, 24 hex chars). - * Collision probability: ~6.3e-12 at 10^9 distinct IDs. * * @remarks **Non-cryptographic hash for observability only.** - * This function uses a fast, non-cryptographic hash algorithm suitable for - * sanitization, telemetry, and UI display. Do NOT use for security-sensitive - * collision resistance or any cryptographic purposes. + * Uses a fast triple-hash algorithm suitable for sanitization, telemetry, + * and UI display. Effective 96-bit space provides low collision probability + * for typical workloads. Do NOT use for security-sensitive purposes. * * @param value - User-provided key string * @returns 24-character hex hash identifier @@ -72,20 +72,28 @@ export function hashKey(value: string): HashId { * Internal helper - backends use this for consistent fence formatting. * 15-digit format guarantees full safety within Lua's 53-bit precision limit * (2^53-1 ≈ 9.007e15) while providing 10^15 capacity (~31.7 years at 1M locks/sec). - * @param value - Fence counter (bigint or number) + * @param value - Fence counter (bigint or integer number) * @returns 15-digit string (e.g., "000000000000001") - * @throws {LockError} "InvalidArgument" if value is negative or exceeds 15-digit limit + * @throws {LockError} "InvalidArgument" if value is not a finite non-negative integer */ export function formatFence(value: bigint | number): string { - // Convert to bigint and enforce integer + range - const n = typeof value === "number" ? BigInt(Math.trunc(value)) : value; + // Validate numbers before BigInt conversion to avoid leaking RangeError + if (typeof value === "number") { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new LockError( + "InvalidArgument", + "Fence must be a finite non-negative integer", + ); + } + } + + const n = typeof value === "bigint" ? value : BigInt(value); if (n < 0n) { throw new LockError("InvalidArgument", "Fence must be non-negative"); } - if (n > 999_999_999_999_999n) { - // 15 digits max (10^15 - 1) + if (n > FENCE_FORMAT_MAX) { throw new LockError( "InvalidArgument", `Fence exceeds 15-digit limit: ${n}`, @@ -144,6 +152,29 @@ export function makeStorageKey( backendLimitBytes: number, reserveBytes: number, ): string { + // Validate configuration (fail fast on misconfigured backends) + if ( + !Number.isFinite(backendLimitBytes) || + !Number.isInteger(backendLimitBytes) || + backendLimitBytes <= 0 + ) { + throw new LockError( + "InvalidArgument", + "backendLimitBytes must be a positive integer", + ); + } + + if ( + !Number.isFinite(reserveBytes) || + !Number.isInteger(reserveBytes) || + reserveBytes < 0 + ) { + throw new LockError( + "InvalidArgument", + "reserveBytes must be a non-negative integer", + ); + } + // Validate key is not empty if (!key) { throw new LockError("InvalidArgument", "Key must not be empty"); diff --git a/package.json b/package.json index 6a5ef49..0b5236e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "syncguard", - "version": "2.5.2", + "version": "2.5.3", "description": "Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.", "keywords": [ "atomic", @@ -91,14 +91,14 @@ }, "devDependencies": { "@google-cloud/firestore": "^8.0.0", - "@types/bun": "1.3.3", - "firebase-tools": "^14.26.0", + "@types/bun": "1.3.4", + "firebase-tools": "^14.27.0", "gh-pages": "^6.3.0", "husky": "^9.1.7", "ioredis": "^5.8.1", "lint-staged": "^16.2.7", "postgres": "^3.4.7", - "prettier": "^3.7.3", + "prettier": "^3.7.4", "prettier-plugin-sql": "^0.19.2", "typescript": "^5.9.3", "vitepress": "^2.0.0-alpha.15" diff --git a/test/unit/common/crypto.test.ts b/test/unit/common/crypto.test.ts index 92b26c2..fe83e60 100644 --- a/test/unit/common/crypto.test.ts +++ b/test/unit/common/crypto.test.ts @@ -149,9 +149,34 @@ describe("makeStorageKey", () => { it("should throw when prefix exceeds backend limit", () => { const longPrefix = "x".repeat(500); expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow( - "Prefix exceeds backend limit", + /Prefix exceeds backend limit/, ); }); + + it("should reject invalid backendLimitBytes", () => { + expect(() => makeStorageKey("p", "k", -1, 0)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 0, 0)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", NaN, 0)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", Infinity, 0)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 1000.5, 0)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", -1, 0)).toThrow( + "backendLimitBytes must be a positive integer", + ); + }); + + it("should reject invalid reserveBytes", () => { + expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 1000, NaN)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 1000, Infinity)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 1000, 0.5)).toThrow(LockError); + expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow( + "reserveBytes must be a non-negative integer", + ); + }); + + it("should allow zero reserveBytes", () => { + expect(() => makeStorageKey("p", "k", 1000, 0)).not.toThrow(); + }); }); describe("hashKey", () => { @@ -245,14 +270,33 @@ describe("formatFence", () => { expect(formatFence(999_999_999_999_999n)).toBe("999999999999999"); }); - it("should truncate floating point numbers", () => { - expect(formatFence(42.9)).toBe("000000000000042"); - expect(formatFence(42.1)).toBe("000000000000042"); + it("should accept values at operational threshold (FENCE_THRESHOLDS.MAX)", () => { + // Sanity check: operational threshold (9e14) < format limit (10^15-1) + expect(formatFence(900_000_000_000_000)).toBe("900000000000000"); + expect(formatFence(900_000_000_000_000n)).toBe("900000000000000"); + }); + + it("should reject non-integer numbers", () => { + expect(() => formatFence(42.9)).toThrow(LockError); + expect(() => formatFence(42.1)).toThrow(LockError); + expect(() => formatFence(0.1)).toThrow(LockError); + }); + + it("should reject negative fractional values", () => { + // Edge case: -0.1 must not be truncated to 0 + expect(() => formatFence(-0.1)).toThrow(LockError); + expect(() => formatFence(-0.0001)).toThrow(LockError); + }); + + it("should reject non-finite values", () => { + expect(() => formatFence(NaN)).toThrow(LockError); + expect(() => formatFence(Infinity)).toThrow(LockError); + expect(() => formatFence(-Infinity)).toThrow(LockError); }); it("should throw for negative values", () => { expect(() => formatFence(-1)).toThrow(LockError); - expect(() => formatFence(-1)).toThrow("Fence must be non-negative"); + expect(() => formatFence(-1)).toThrow("finite non-negative integer"); expect(() => formatFence(-1n)).toThrow("Fence must be non-negative"); });