Skip to content

Commit ef2fbe2

Browse files
joker23claudekinyoklion
authored
chore(node-client): harden existing platform implementation (#1403)
Hardens the Node platform layer landed in #1393, ahead of the client-side SDK implementation that builds on it. First atomic in the node-client port stack. - **NodeStorage**: log (no longer swallow) flush errors; validate/log on malformed cache reads and ignore non-string cache values; write the temp file via an exclusive `wx` open so the write cannot be redirected through a symlink; warn when `getNodeStorage` is called with a `localStoragePath` that differs from the process singleton's. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes on-disk cache semantics and filesystem write behavior; low blast radius but incorrect handling could affect flag persistence or local file security. > > **Overview** > Hardens the Node client **platform** layer around flag disk cache and tightens related tests. > > **`NodeStorage`** now treats a missing cache file as a normal first run (no warning, no empty rewrite). Malformed or unreadable cache files trigger a **warn** and recovery write; loaded JSON must be a plain object and only **string** values are kept. Persists via an exclusive **`wx`** temp open (after removing any existing temp path) so writes cannot follow a symlink planted on the temp file path. **`getNodeStorage`** records the first `localStoragePath` and **warns** if a later call passes a different path; **`resetNodeStorage`** clears that path tracking too. > > **Tests** cover first-run behavior, malformed JSON warnings, non-string cache entries, symlink safety, singleton path mismatch warnings, and **`NodeInfo`** no longer pins the SDK **version** string in expectations. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1962f54. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
1 parent c012e57 commit ef2fbe2

3 files changed

Lines changed: 115 additions & 10 deletions

File tree

packages/sdk/node-client/__tests__/platform/NodeInfo.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ afterEach(() => {
2424

2525
it('returns sdk data with the package name, version, and user-agent base', () => {
2626
const info = new NodeInfo();
27-
expect(info.sdkData()).toEqual({
28-
name: 'node-client-sdk',
29-
version: '0.0.1',
30-
userAgentBase: 'NodeClient',
31-
});
27+
const data = info.sdkData();
28+
29+
expect(data.name).toEqual('node-client-sdk')
30+
expect(data.userAgentBase).toEqual('NodeClient')
3231
});
3332

3433
it('reports the runtime node version under platformData additional', () => {

packages/sdk/node-client/__tests__/platform/NodeStorage.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,65 @@ it('recovers when the storage file contains invalid JSON', async () => {
7070
await expect(storage.get('alpha')).resolves.toBe('one');
7171
});
7272

73+
it('does not warn on first run when the cache file does not exist', async () => {
74+
const logger = createMockLogger();
75+
const storage = new NodeStorage(tmpRoot, logger);
76+
await expect(storage.get('anything')).resolves.toBeNull();
77+
78+
expect(logger.warn).not.toHaveBeenCalled();
79+
});
80+
81+
it('does not preemptively write the cache file on first run', async () => {
82+
const storage = new NodeStorage(tmpRoot);
83+
await expect(storage.get('anything')).resolves.toBeNull();
84+
85+
await expect(fs.access(path.join(tmpRoot, 'ldcache.json'))).rejects.toMatchObject({
86+
code: 'ENOENT',
87+
});
88+
});
89+
90+
it('warns when the cache file is not valid JSON', async () => {
91+
await fs.writeFile(path.join(tmpRoot, 'ldcache.json'), 'not json', 'utf8');
92+
93+
const logger = createMockLogger();
94+
const storage = new NodeStorage(tmpRoot, logger);
95+
await expect(storage.get('anything')).resolves.toBeNull();
96+
97+
expect(logger.warn).toHaveBeenCalledWith(
98+
expect.stringContaining('Discarding malformed flag cache'),
99+
);
100+
});
101+
102+
it('ignores non-string values when loading the cache', async () => {
103+
await fs.writeFile(
104+
path.join(tmpRoot, 'ldcache.json'),
105+
JSON.stringify({ good: 'keep', obj: { nested: true }, arr: [1, 2], num: 5 }),
106+
'utf8',
107+
);
108+
109+
const storage = new NodeStorage(tmpRoot);
110+
await expect(storage.get('good')).resolves.toBe('keep');
111+
await expect(storage.get('obj')).resolves.toBeNull();
112+
await expect(storage.get('arr')).resolves.toBeNull();
113+
await expect(storage.get('num')).resolves.toBeNull();
114+
});
115+
116+
it('does not follow a symlink planted at the temp file path', async () => {
117+
const storage = new NodeStorage(tmpRoot);
118+
// Ensure initialization (which clears any temp file) has completed before planting.
119+
await storage.get('warmup');
120+
121+
const victim = path.join(tmpRoot, 'victim.txt');
122+
await fs.writeFile(victim, 'protected', 'utf8');
123+
await fs.symlink(victim, path.join(tmpRoot, 'ldcache.json.tmp'));
124+
125+
await storage.set('alpha', 'one');
126+
127+
// The exclusive open removes the symlink and writes a fresh file, so the victim is untouched.
128+
await expect(fs.readFile(victim, 'utf8')).resolves.toBe('protected');
129+
await expect(storage.get('alpha')).resolves.toBe('one');
130+
});
131+
73132
it('logs and returns sentinel values when initialization fails', async () => {
74133
const filePath = path.join(tmpRoot, 'not-a-dir');
75134
await fs.writeFile(filePath, 'sentinel', 'utf8');
@@ -104,3 +163,14 @@ it('rebuilds the singleton after resetNodeStorage', () => {
104163
const second = getNodeStorage(tmpRoot);
105164
expect(second).not.toBe(first);
106165
});
166+
167+
it('warns when getNodeStorage is called with a different localStoragePath', () => {
168+
getNodeStorage(tmpRoot);
169+
170+
const logger = createMockLogger();
171+
getNodeStorage(path.join(tmpRoot, 'different'), logger);
172+
173+
expect(logger.warn).toHaveBeenCalledWith(
174+
expect.stringContaining('different localStoragePath'),
175+
);
176+
});

packages/sdk/node-client/src/platform/NodeStorage.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ export default class NodeStorage implements Storage {
4646
try {
4747
const data = await fs.readFile(this._storageFile, 'utf8');
4848
const parsed = JSON.parse(data);
49-
if (parsed && typeof parsed === 'object') {
50-
this._cache = new Map(Object.entries(parsed as Record<string, string>));
49+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
50+
const entries = Object.entries(parsed).filter(
51+
([, value]) => typeof value === 'string',
52+
) as [string, string][];
53+
this._cache = new Map(entries);
54+
}
55+
} catch (error) {
56+
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
57+
this._logger?.warn(
58+
`Discarding malformed flag cache at ${this._storageFile}: ${error instanceof Error ? error.message : error}`,
59+
);
60+
await this._atomicWriteToFile(this._cache);
5161
}
52-
} catch {
53-
await this._atomicWriteToFile(this._cache);
5462
}
5563

5664
return true;
@@ -62,10 +70,31 @@ export default class NodeStorage implements Storage {
6270

6371
private async _atomicWriteToFile(data: Map<string, string>): Promise<void> {
6472
const content = JSON.stringify(Object.fromEntries(data));
73+
let handle: fs.FileHandle | undefined;
6574
try {
66-
await fs.writeFile(this._tempFile, content, { encoding: 'utf8', mode: 0o600 });
75+
try {
76+
await fs.unlink(this._tempFile);
77+
} catch {
78+
// Either the temp file didn't exist, which we don't care about or there was
79+
// a problem deleting the file, for example a problem with permissions, in
80+
// which case the subsequent write will likely also fail and be handled by its
81+
// exception handler.
82+
}
83+
handle = await fs.open(this._tempFile, 'wx', 0o600);
84+
await handle.writeFile(content, 'utf8');
85+
await handle.close();
86+
handle = undefined;
6787
await fs.rename(this._tempFile, this._storageFile);
6888
} catch (error) {
89+
if (handle) {
90+
try {
91+
await handle.close();
92+
} catch (closeError) {
93+
this._logger?.warn(
94+
`Failed to close storage temp file during cleanup: ${closeError}`,
95+
);
96+
}
97+
}
6998
try {
7099
await fs.unlink(this._tempFile);
71100
} catch {
@@ -142,15 +171,22 @@ export default class NodeStorage implements Storage {
142171
// process share the same cache file. The first call's storagePath / logger wins;
143172
// later calls ignore the arguments.
144173
let instance: NodeStorage | undefined;
174+
let instancePath: string | undefined;
145175

146176
export function getNodeStorage(storagePath?: string, logger?: LDLogger): NodeStorage {
147177
if (!instance) {
148178
instance = new NodeStorage(storagePath, logger);
179+
instancePath = storagePath;
180+
} else if (storagePath !== undefined && storagePath !== instancePath) {
181+
logger?.warn(
182+
`NodeStorage was already initialized with a different localStoragePath; ignoring '${storagePath}'.`,
183+
);
149184
}
150185
return instance;
151186
}
152187

153188
/** @internal Visible for testing only. */
154189
export function resetNodeStorage(): void {
155190
instance = undefined;
191+
instancePath = undefined;
156192
}

0 commit comments

Comments
 (0)